Compare commits

...
Author SHA1 Message Date
renovate Bot b61386abd6 chore(deps): update github actions
Rust CI / cargo clippy (pull_request) Waiting to run
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Waiting to run
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Waiting to run
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Waiting to run
Rust CI / cargo doc (pull_request) Waiting to run
Fuzz Smoke / cargo fuzz smoke (pull_request) Waiting to run
Supply chain / actionlint (pull_request) Waiting to run
Rust CI / cargo fmt (pull_request) Waiting to run
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Waiting to run
Netem Interop / netem interop (pull_request) Waiting to run
Supply chain / cargo-deny advisories (pull_request) Waiting to run
Supply chain / cargo-deny bans licenses sources (pull_request) Waiting to run
2026-10-16 00:07:40 +00: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 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 / actionlint (push) Successful in 3s
Semver / cargo-semver-checks (push) Successful in 54s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 27s
Supply chain / cargo-deny advisories (push) Successful in 8s
Publish irtt-server image / publish (push) Successful in 1m8s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
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 check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo doc (push) Successful in 30s
Supply chain / cargo-deny advisories (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
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 check (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 29s
Supply chain / cargo-deny advisories (push) Successful in 5s
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m10s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
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 check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 33s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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 check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
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 check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
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 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 check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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
Nils Andreas Svee 0a928dcf8e Merge branch 'feature/simplify-client-output'
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 7s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Successful in 1m5s
2026-10-05 23:08:43 +07:00
Nils Andreas Svee e13802ccdb refactor(app): simplify client output and remove duplicate statistics models 2026-10-05 23:00:05 +07:00
Nils Andreas Svee d81784f0f6 Merge branch 'feature/async-client-frontend'
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 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 1m11s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 30s
Netem Interop / netem interop (push) Successful in 1m4s
2026-10-05 22:04:35 +07:00
Nils Andreas Svee b1b0b62848 chore(app): remove unnecessary tests 2026-10-05 22:04:07 +07:00
Nils Andreas Svee 5067ceeb1c feat(tui): adopt an asynchronous Tokio runtime for the TUI frontend
Refactor TUI event handling and rendering to improve responsiveness and use a unified managed client worker.
2026-10-05 21:49:36 +07:00
Nils Andreas Svee 66abb792ce feat(client): adopt an asynchronous Tokio runtime for the client frontend 2026-10-05 21:01:54 +07:00
Nils Andreas Svee 1f7df1ebc9 Merge branch 'feat/tui-rework'
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 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 27s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m7s
2026-10-05 19:40:44 +07:00
Nils Andreas Svee 1eed942423 feat(tui): rework UI with unified dashboard, interactive details, and centralized input
Replaces separate Graph and Dashboard views with a single dashboard, integrating multi-target selection and a scrollable details sheet. Centralizes key input handling for a more consistent and responsive user experience.
2026-10-05 18:16:12 +07:00
Nils Andreas Svee 0e32c76c78 ci(osv-scanner): Enable manual dispatch and update workflow configuration
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 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 31s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 33s
Netem Interop / netem interop (push) Successful in 1m5s
Remove deprecated scan arguments as OSV v2 defaults now cover recursive scanning.
2026-10-05 00:46:32 +07:00
Nils Andreas Svee e65d415c7d Merge branch 'refactor/managed-task-modules'
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 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
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 1m16s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m2s
2026-10-05 00:34:09 +07:00
Nils Andreas Svee e7de8bdb30 refactor(client): split managed task into coherent internal modules
Group control and construction, target lifecycle, target-set transactions,
send pacing and commitment, and bounded timeout processing into private
children of the existing task owner. Keep durable status, outcome history,
top-level polling, sealing, and abandonment in task.rs.

Preserve phase-owned resources, stop/update synchronization, publication
ordering, pacing, and timeout fairness. Move the existing private gate and
timeout tests with their implementation without adding coverage or hooks.
2026-10-05 00:24:15 +07:00
renovate Bot b9cc3e2779 chore(deps): update rust docker tag to v1.99
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (pull_request) Successful in 24s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Successful in 22s
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Failing after 8s
Rust CI / cargo doc (pull_request) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 28s
Supply chain / actionlint (pull_request) Successful in 2s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (pull_request) Successful in 12s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Successful in 1m5s
Supply chain / cargo-deny advisories (pull_request) Successful in 6s
Supply chain / cargo-deny bans licenses sources (pull_request) Successful in 5s
Netem Interop / netem interop (pull_request) Successful in 1m2s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m12s
2026-10-04 17:02:58 +00:00
Nils Andreas Svee b7890f1190 Merge branch 'feature/managed-task-phase-status'
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 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-05 00:02:09 +07:00
Nils Andreas Svee bc9ea0fe99 fix(client): baseline status subscriptions at the current snapshot 2026-10-04 23:51:19 +07:00
Nils Andreas Svee 2a2dd47e38 refactor(client): make task phases and durable status authoritative 2026-10-04 23:17:38 +07:00
Nils Andreas Svee 236b80db1d Merge branch 'feature/managed-terminal-outcome'
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 10s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m4s
2026-10-04 22:05:25 +07:00
Nils Andreas Svee 2cd8a7d60d refactor(client): make terminal target outcomes authoritative 2026-10-04 21:48:20 +07:00
Nils Andreas Svee 784ce77354 Merge branch 'feature/managed-target-phase-ownership'
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
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m15s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m6s
2026-10-04 21:31:32 +07:00
Nils Andreas Svee 6ebecaec85 refactor(client): make managed target phases own runtime resources 2026-10-04 21:23:57 +07:00
renovate Bot 664ef56b6e chore(deps): maintain cargo lockfile
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 28s
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
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (pull_request) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Successful in 21s
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Failing after 9s
Rust CI / cargo doc (pull_request) Successful in 22s
Supply chain / actionlint (pull_request) Successful in 2s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (pull_request) Successful in 13s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Successful in 1m6s
Supply chain / cargo-deny advisories (pull_request) Successful in 6s
Supply chain / cargo-deny bans licenses sources (pull_request) Successful in 5s
Netem Interop / netem interop (pull_request) Successful in 1m12s
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m9s
2026-10-04 13:55:49 +00:00
Nils Andreas Svee 5cffd2e56a chore(deps): update dependencies
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 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny advisories (push) Successful in 10s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Supply chain / cargo-deny bans licenses sources (push) Successful in 10s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m10s
2026-10-04 20:54:16 +07:00
Nils Andreas Svee c9e539933b Merge branch 'feature/explicit-probe-send-commitment'
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
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 1m2s
2026-10-04 20:46:50 +07:00
Nils Andreas Svee a6ba335302 feat(client): expose probe send commitment through typed receipts 2026-10-04 19:14:06 +07:00
Nils Andreas Svee eebe83986a Merge branch 'refactor/typed-client-negotiation'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 33s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 29s
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 1m12s
2026-10-04 18:34:31 +07:00
Nils Andreas Svee d467211f41 refactor(client): separate accepted negotiation state from peer evidence 2026-10-04 18:19:28 +07:00
Nils Andreas Svee f62e6a9953 Merge branch 'feat/client-config-ownership'
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 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 11s
Rust CI / cargo clippy (push) Successful in 33s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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 28s
Netem Interop / netem interop (push) Successful in 1m2s
2026-10-04 17:56:00 +07:00
Nils Andreas Svee 20960e1b8d refactor(client): make configuration reusable across endpoints 2026-10-04 17:30:18 +07:00
Nils Andreas Svee c436bfb686 Merge branch 'feature/authentication-types'
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 9s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 10s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 29s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m12s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m5s
2026-10-04 16:59:10 +07:00
Nils Andreas Svee 156db4c720 refactor(client): make authentication explicit and redact HMAC keys 2026-10-04 16:58:44 +07:00
Nils Andreas Svee 9c703ecff2 Merge branch 'fix/managed-finite-lifetime-wake'
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
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 37s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 19s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-04 15:47:51 +07:00
Nils Andreas Svee 798efaf360 fix(client): wake finite managed schedules under backpressure
Expose the negotiated schedule end only while the schedule is unfinished,
and include it among Active targets' non-send deadlines. This keeps finite
expiry independent of socket writability and of Burst/Staggered pacing.
Staggered release gating continues to apply only to send opportunities.

The existing expiry path marks the schedule finished, removing its lifetime
deadline before pending probes continue draining. No new state, readiness
handling, cadence, or drain/close policy is introduced. Continuous schedules
have no lifetime deadline.

The prior public-API Linux namespace/veth/TBF investigation observed genuine
EAGAIN with Burst pacing, 500 ms duration, 1 ms interval, 1400-byte probes,
and 20 ms probe timeout. Polling stopped around 327 ms after pending probes
disappeared, remained stalled beyond 1.5 s, and resumed on queue release.
Staggered applicability follows from the shared deadline selection; it was
not separately reproduced under TBF.

Retain two deterministic owning-layer tests for schedule deadline lifetime
and managed selection under send_waiting and stagger gating, using ordinary
Instant values and a production socket. Keep the privileged networking
harness as investigation evidence rather than adding bespoke test machinery.

Validation: formatting; focused schedule/managed tests; managed lifecycle,
behavior, and blocking-owner integration tests; workspace all-feature check,
all-target tests, doctests, and strict Clippy; client no-default-features
check/tests/strict Clippy and Tokio-free normal dependency tree; Linux
all-feature client and integration suites, including ancillary transport.
2026-10-04 15:41:06 +07:00
Nils Andreas Svee 4b05c3027b fix(client): bound managed drain deadline bookkeeping
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 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m2s
Reuse deadline-ordered linked storage in the separate timed-out retention map so each drain lookup reads its tail and late replies unlink directly. Preserve FIFO capacity eviction, original timeouts, classification, and the managed work budget; cover bounded inspection work and retention lifecycle transitions.
2026-10-04 13:57:37 +07:00
Nils Andreas Svee 596dd20bcf fix(client): validate blocking receive lifecycle before socket I/O
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 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 15s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m10s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 59s
Blocking receives previously checked lifecycle only after a datagram arrived, allowing invalid operations to block or return empty success after timeout. Reuse session validation before socket access and cover connected, closed, and no-test-completed states through public APIs.
2026-10-04 13:35:37 +07:00
Nils Andreas Svee ebc1f2d0b1 Merge branch 'fix/rolling-directional-loss'
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 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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 1m15s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-04 13:20:00 +07:00
Nils Andreas Svee a45396c236 fix(stats): reject stale and discontinuous rolling counter baselines 2026-10-04 13:12:46 +07:00
Nils Andreas Svee 743a050416 fix(stats): align rolling directional loss with window counts 2026-10-04 12:20:47 +07:00
Nils Andreas Svee 524d56f72c Merge branch 'hotfix/fix-staggered-idle-wake'
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
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 30s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m6s
2026-10-04 12:04:15 +07:00
Nils Andreas Svee ccdf4ea39c test(client): simplify staggered deadline regression 2026-10-04 11:55:04 +07:00
Nils Andreas Svee 1d211c81ad fix(client): avoid staggered wakes without a send deadline 2026-10-04 11:37:30 +07:00
Nils Andreas Svee ded61b58e2 Merge branch 'fix/rolling-time-ordering'
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 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 5s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
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 1m16s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m2s
2026-10-04 10:51:23 +07:00
Nils Andreas Svee bdc5d152d4 fix(stats): reclaim expired rolling events without tombstones 2026-10-04 00:52:58 +07:00
Nils Andreas Svee 8986caecb0 perf(stats): index rolling expiry to avoid full-window scans 2026-10-04 00:06:15 +07:00
Nils Andreas Svee edff75e55a fix(stats): expire backdated events from rolling time windows 2026-10-03 22:29:42 +07:00
Nils Andreas Svee 48a50d7f3a tests: rework the actually useful tests to live in the integration crate
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 10s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m11s
2026-10-03 13:59:38 +07:00
Nils Andreas Svee 74e7532965 tests: clean up more tests across the project
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-02 12:47:27 +07:00
Nils Andreas Svee dc5b19059e tests(client): cleanup unnecessary tests and hooks
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo clippy (push) Successful in 34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 34s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-02 08:33:49 +07:00
Nils Andreas Svee 50fe83a4c8 docs(irtt-client): document caller-owned cadence
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 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
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 1m4s
2026-10-02 00:31:17 +07:00
Nils Andreas Svee f2fce2f330 refactor(irtt-client): move cadence into managed runner 2026-10-02 00:31:17 +07:00
renovate Bot f350c82dae chore(deps): maintain cargo lockfile
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 24s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 5m37s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m4s
2026-10-01 23:25:15 +07:00
renovate Bot 22d3f6cb27 fix(deps): update rust crate thiserror to 2.0.21 2026-10-01 23:25:15 +07:00
renovate Bot 85238682b8 chore(deps): update github actions to 2892aa5 2026-10-01 23:25:05 +07:00
Nils Andreas Svee 9240152e4b chore(release): irtt-client 0.5.3
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
Supply chain / actionlint (push) Successful in 3s
Semver / cargo-semver-checks (push) Failing after 48s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m3s
Add socket routing configuration for interface binding, packet marks, and FreeBSD FIB selection.
2026-09-28 14:37:30 +07:00
Nils Andreas Svee 7bbc89e1c0 feat(socket): support advanced routing configuration for client socket
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 8s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 30s
Netem Interop / netem interop (push) Successful in 1m5s
Enable binding to a specific network interface, applying a firewall mark, or selecting a routing table (FIB) for outgoing packets on supported platforms.
2026-09-28 14:24:21 +07:00
Nils Andreas Svee b6043e4961 refactor(stats): use shared measurement statistics
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
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Netem Interop / netem interop (push) Successful in 1m2s
2026-09-27 00:03:35 +07:00
Nils Andreas Svee b36842ee98 chore(release): publish docs for irtt-proto, irtt-server, irtt-stats
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Supply chain / actionlint (push) Successful in 3s
Semver / cargo-semver-checks (push) Successful in 54s
Rust CI / cargo clippy (push) Successful in 26s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 13s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 44s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Netem Interop / netem interop (push) Successful in 1m2s
Version the remaining public crates independently so their crate-specific READMEs, examples, and API doctests are represented in release history.
2026-09-25 00:32:11 +07:00
Nils Andreas Svee cf48988170 chore(release): irtt-client 0.5.2, irtt-rs 0.7.1
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Supply chain / actionlint (push) Successful in 3s
Semver / cargo-semver-checks (push) Successful in 58s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 7s
Publish irtt-server image / publish (push) Successful in 1m13s
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo clippy (push) Successful in 29s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 43s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m8s
Fix kernel transmit-timestamp correlation after failed probe sends and accept ordinary boolean values for the server DSCP environment option.
2026-09-25 00:29:52 +07:00
renovate Bot 41aee2a551 fix(deps): update rust crate clap to 4.6.7
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
2026-09-24 10:21:00 +00:00
renovate Bot 722539c8d7 chore(deps): update github actions to v2
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
2026-09-24 10:20:48 +00:00
renovate Bot 8369ec3502 chore(deps): update github actions
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
2026-09-24 10:19:59 +00:00
renovate Bot 1df69969db chore(deps): maintain cargo lockfile
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
2026-09-24 10:19:40 +00:00
Nils Andreas Svee 4908921fb8 Merge branch 'docs/doctests'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
2026-09-24 17:13:02 +07:00
Nils Andreas Svee 0d69255e9e docs(client): add executable API examples 2026-09-24 16:51:03 +07:00
Nils Andreas Svee d7d6e76682 docs(server): add executable API examples 2026-09-24 16:40:04 +07:00
Nils Andreas Svee 328ff17901 docs(irtt-proto): add runnable doctests for public API
Add executable doctests documenting the consumer-facing contract of
irtt-proto: the directional request encode/decode/verify split, Params
construction and round-trip, omitted-vs-explicit-zero presence, open and
echo reply round trips, packet length calculation, and HMAC verification.

No conventional tests are migrated: every existing irtt-proto test
exercises wire vectors, flag matrices, malformed input, layout rules,
HMAC error behavior, or encode/decode round trips, which the crate's
AGENTS.md treats as durable compatibility coverage rather than
documentation cleanup targets.
2026-09-24 16:29:40 +07:00
Nils Andreas Svee 19782dd3fd docs(irtt-stats): add runnable doctests for public API
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 45s
Rust CI / cargo doc (push) Successful in 38s
Supply chain / cargo-deny advisories (push) Successful in 10s
Rust CI / cargo fmt (push) Successful in 22s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m31s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 42s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m44s
Netem Interop / netem interop (push) Successful in 1m13s
Add executable doctests that document the consumer-facing contract of
irtt-stats: finite vs continuous retention, the exact-median guarantee,
the retained-memory planning estimate, and count-/time-based rolling
snapshots. Migrate the send-call exact-median test into the
StatsConfig::finite() doc, which fully subsumes it.
2026-09-18 00:10:49 +07:00
Nils Andreas Svee b3241958d9 Merge branch 'test/server-core-fuzz'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 39s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m32s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo doc (push) Successful in 38s
Supply chain / cargo-deny advisories (push) Successful in 10s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m21s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Netem Interop / netem interop (push) Successful in 1m18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m49s
2026-09-15 16:29:21 +07:00
Nils Andreas Svee 8644b70b89 test(server): vary fuzz HMAC keys 2026-09-15 14:24:51 +07:00
Nils Andreas Svee 196512068b Merge branch 'docs/audit-corrections'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m34s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m35s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo clippy (push) Successful in 43s
Rust CI / cargo doc (push) Successful in 32s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m29s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Netem Interop / netem interop (push) Successful in 1m12s
2026-09-15 13:20:25 +07:00
Nils Andreas Svee e861de5247 fix(client): invalidate TX correlation after send failures 2026-09-15 13:03:36 +07:00
Nils Andreas Svee d335ad78c9 test(server): exercise keyed stateful fuzz paths 2026-09-15 12:59:53 +07:00
Nils Andreas Svee f650b6220a Merge branch 'test/doctest-ci'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 40s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m30s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo doc (push) Successful in 33s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 15s
Rust CI / cargo clippy (push) Successful in 37s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
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 1m17s
Netem Interop / netem interop (push) Successful in 1m16s
2026-09-15 12:51:01 +07:00
Nils Andreas Svee 865c166828 Merge branch 'refactor/remove-irttd-alias'
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m31s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m31s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo clippy (push) Successful in 42s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 19s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m28s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Netem Interop / netem interop (push) Successful in 1m14s
2026-09-15 12:50:25 +07:00
Nils Andreas Svee bc2bf34ed4 test(server): fuzz ServerCore datagram handling 2026-09-15 12:39:59 +07:00
Nils Andreas Svee 4864357486 refactor(app): remove undocumented irttd applet alias 2026-09-15 12:32:34 +07:00
Nils Andreas Svee 188d6e5354 docs(client): clarify terminal open replies 2026-09-15 12:31:45 +07:00
Nils Andreas Svee 2882beb2c9 docs(client): clarify TX timestamp correlation 2026-09-15 12:31:26 +07:00
Nils Andreas Svee ed805e854f docs(server): document DSCP-safe listener pairing 2026-09-15 12:31:10 +07:00
Nils Andreas Svee f2e9c72b22 ci: run workspace doctests 2026-09-15 12:29:03 +07:00
Nils Andreas Svee 4563816873 docs(tui): advise on memory usage for finite-duration runs
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 42s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m29s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m29s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m27s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Netem Interop / netem interop (push) Successful in 1m22s
2026-09-14 15:33:57 +07:00
Nils Andreas Svee 70a2998f8c docs(server): update --bind default bind reference 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 4ddefb2101 docs(server): document all IRTT_SERVER_ environment variables 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 04291a0b7b feat(server): allow IRTT_SERVER_NO_DSCP to set --no-dscp flag 2026-09-14 15:33:57 +07:00
Nils Andreas Svee c426884fd1 fix(client): prevent stagger gate from accumulating wakeup latency 2026-09-14 15:33:56 +07:00
Nils Andreas Svee b9c2d024d9 chore(release): irtt-client 0.5.1, irtt-rs 0.7.0
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m51s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m45s
Supply chain / actionlint (push) Successful in 7s
Semver / cargo-semver-checks (push) Successful in 1m31s
Rust CI / cargo doc (push) Successful in 38s
Supply chain / cargo-deny advisories (push) Successful in 14s
Publish irtt-server image / publish (push) Successful in 3m9s
Rust CI / cargo fmt (push) Successful in 40s
Rust CI / cargo clippy (push) Successful in 1m3s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m48s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m3s
Netem Interop / netem interop (push) Successful in 1m36s
irtt-client exposes a synchronous wait for managed target-set updates and keeps its packaged test dependency resolvable from crates.io. irtt-rs adds stdin-managed desired target sets and per-target HMAC configuration.
2026-09-01 12:55:44 +07:00
Nils Andreas Svee 7ee7474605 fix(ci): use GNU target for fuzzing
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 59s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m50s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m41s
Supply chain / actionlint (push) Successful in 7s
Rust CI / cargo clippy (push) Successful in 1m1s
Rust CI / cargo doc (push) Successful in 47s
Supply chain / cargo-deny advisories (push) Successful in 15s
Rust CI / cargo fmt (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m44s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m1s
Netem Interop / netem interop (push) Successful in 1m31s
2026-09-01 12:48:01 +07:00
Nils Andreas Svee c18903b097 ci: download Gitea tool binaries
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 51s
Proto Fuzz Smoke / cargo fuzz smoke (push) Failing after 41s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m43s
Supply chain / actionlint (push) Successful in 7s
Rust CI / cargo doc (push) Successful in 44s
Supply chain / cargo-deny advisories (push) Successful in 14s
Rust CI / cargo fmt (push) Successful in 42s
Rust CI / cargo clippy (push) Successful in 1m4s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m3s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m47s
Netem Interop / netem interop (push) Successful in 1m29s
2026-09-01 12:27:45 +07:00
Nils Andreas Svee b397916ec6 fix(ci): run cargo-deny directly on Gitea
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 51s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m55s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / actionlint (push) Failing after 14s
Rust CI / cargo clippy (push) Successful in 54s
Rust CI / cargo doc (push) Successful in 43s
Supply chain / cargo-deny advisories (push) Successful in 2m34s
Rust CI / cargo fmt (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m42s
Supply chain / cargo-deny bans licenses sources (push) Successful in 2m25s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 53s
Netem Interop / netem interop (push) Successful in 1m32s
2026-09-01 12:06:59 +07:00
Nils Andreas Svee 9f6ef340f9 docs(managed): fix broken intra-doc link for BlockingManagedClient
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 59s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m49s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo clippy (push) Successful in 55s
Rust CI / cargo doc (push) Successful in 47s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m43s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 50s
Netem Interop / netem interop (push) Successful in 1m33s
Update the link path to use a crate-relative reference, ensuring the documentation resolves correctly for users.
2026-09-01 12:02:01 +07:00
Nils Andreas Svee 3a29b4efb2 ci: make Gitea primary Linux forge
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 50s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m49s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m3s
Supply chain / actionlint (push) Failing after 14s
Rust CI / cargo clippy (push) Successful in 1m6s
Rust CI / cargo doc (push) Failing after 48s
Supply chain / cargo-deny advisories (push) Failing after 29s
Rust CI / cargo fmt (push) Successful in 45s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m48s
Supply chain / cargo-deny bans licenses sources (push) Failing after 27s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m4s
Netem Interop / netem interop (push) Successful in 1m33s
2026-09-01 11:18:51 +07:00
Lochnair a2f542bb97 Merge pull request 'feat(app): add stdin-managed targets and per-target HMAC' (#136) from feature/targets-stdin into main
Reviewed-on: #136
2026-09-01 04:06:19 +00:00
Nils Andreas Svee 5dd478e81a fix(app): bound dynamic stream accounting 2026-09-01 10:36:05 +07:00
Nils Andreas Svee 1ba4f61072 feat(app): add stdin-managed client target sets 2026-09-01 01:50:56 +07:00
Nils Andreas Svee 31fd76f78d feat(client): await managed updates from blocking callers 2026-09-01 01:49:59 +07:00
Nils Andreas Svee 344533f8b6 feat(app): parse target-level HMAC authentication 2026-09-01 01:49:48 +07:00
renovate Bot e8b3f64292 Merge pull request 'chore(deps): update github actions to v0.6.3' (#135) from renovate/github-actions into main 2026-08-30 22:07:41 +00:00
renovate Bot c98452aa2c chore(deps): update github actions to v0.6.3 2026-08-30 22:07:36 +00:00
renovate Bot 807d8f2846 chore(deps): maintain cargo lockfile 2026-08-29 14:40:37 +00:00
renovate Bot 0cc6b1ea80 chore(deps): update github actions 2026-08-29 14:40:09 +00:00
renovate Bot 0ccca9b0ab chore(deps): update rust docker tag to v1.98 2026-08-29 14:39:18 +00:00
renovate Bot df0d69e35d fix(deps): update rust crate libfuzzer-sys to 0.4.13 2026-08-29 14:38:56 +00:00
renovate Bot 9ab1b78637 chore(deps): pin dependencies 2026-08-29 14:36:38 +00:00
Nils Andreas Svee 45351e0ba1 chore(release): irtt-server 0.5.2, irtt-rs 0.6.2
Publish irtt-server image / publish (push) Successful in 1m17s
irtt-server's hex-pattern chunking moves to the stable as_chunks API
with no behavior change. irtt-rs picks up IRTT_SERVER_* environment
variable configuration, SIGTERM handling via ctrlc's termination
feature, and the Dockerfile/Gitea Actions pipeline that publishes its
server image to Gitea and GHCR.
2026-08-26 17:03:47 +07:00
Nils Andreas Svee e2b70ac50a ci: add Dockerfile and Gitea Actions workflow for irtt-server
Introduce a multi-platform build pipeline to automate the publication of a minimal, statically linked irtt-server image to Gitea and GHCR.
2026-08-25 00:50:04 +07:00
Nils Andreas Svee 95037f7fa0 fix(clippy): use as_chunks instead of chunks_exact 2026-08-25 00:16:21 +07:00
Nils Andreas Svee 614579b8b6 build(app): enable termination feature for ctrlc dependency
GitHub Actions Security / zizmor (push) Failing after 3m52s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m57s
Supply chain / actionlint (push) Failing after 38s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
GitHub Actions Security / actionlint (push) Failing after 36s
Rust CI / cargo clippy (push) Failing after 45s
Rust CI / cargo doc (push) Successful in 45s
Supply chain / cargo-deny advisories (push) Failing after 9s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 40s
Supply chain / cargo-deny bans licenses sources (push) Failing after 8s
Clippy SARIF / Clippy SARIF (push) Failing after 5m13s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Netem Interop / netem interop (push) Failing after 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Enable the termination feature to allow the application to handle SIGTERM signals gracefully, ensuring proper cleanup during shutdown.
2026-08-24 16:13:23 +07:00
Lochnair 46d7fdfe30 Merge pull request 'feat(app): add environment variable support for server configuration' (#129) from feature/clap-env into main
GitHub Actions Security / zizmor (push) Failing after 3m37s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / actionlint (push) Failing after 46s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
GitHub Actions Security / actionlint (push) Failing after 46s
Rust CI / cargo clippy (push) Failing after 45s
Rust CI / cargo doc (push) Successful in 46s
Supply chain / cargo-deny advisories (push) Failing after 8s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 42s
Supply chain / cargo-deny bans licenses sources (push) Failing after 8s
Clippy SARIF / Clippy SARIF (push) Failing after 5m22s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Netem Interop / netem interop (push) Failing after 49s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Reviewed-on: Lochnair/irtt-rs#129
2026-08-24 08:56:47 +00:00
Nils Andreas Svee 8602f73728 feat(app): add environment variable support for server configuration
GitHub Actions Security / actionlint (pull_request) Failing after 46s
GitHub Actions Security / zizmor (pull_request) Failing after 3m46s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (pull_request) Canceled after 0s
Release / announce (pull_request) Canceled after 0s
Rust CI / cargo clippy (pull_request) Failing after 41s
Rust CI / cargo doc (pull_request) Successful in 46s
Proto Fuzz Smoke / cargo fuzz smoke (pull_request) Successful in 2m0s
Supply chain / actionlint (pull_request) Failing after 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (pull_request) Canceled after 0s
Release / plan (pull_request) Canceled after 0s
Release / build-global-artifacts (pull_request) Canceled after 0s
Release / host (pull_request) Canceled after 0s
Rust CI / cargo fmt (pull_request) Successful in 36s
Netem Interop / netem interop (pull_request) Failing after 40s
Supply chain / cargo-deny advisories (pull_request) Failing after 8s
Supply chain / cargo-deny bans licenses sources (pull_request) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Canceled after 0s
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (pull_request) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-musl) (pull_request) Canceled after 0s
Enable configuration via IRTT_SERVER_* environment variables to simplify deployments in containerized or orchestrated environments. This adds the clap "env" feature and includes thread-safe tests to verify variable precedence.
2026-08-24 15:47:09 +07:00
Nils Andreas Svee 5d07853893 chore(release): irtt-server 0.5.1, irtt-rs 0.6.1
irtt-server gains address_family_available() as a public helper, and
irtt-rs picks up the server zero-argument defaults and single-family
fallback that depend on it.
2026-08-20 22:39:40 +07:00
Nils Andreas Svee ccb31a59e7 Merge pull request #128 from Lochnair/feat/docs-layout-and-server-defaults
Server bind defaults + documentation architecture cleanup
2026-08-20 22:29:02 +07:00
Nils Andreas Svee 56a2a653d0 docs(crates): give each published crate its own crates.io README and examples
Each published crate (irtt-rs, irtt-client, irtt-server, irtt-stats,
irtt-proto) now points crates.io at its own README instead of
inheriting the root project README via the workspace-wide readme
default, which is removed. Each crate README is a short landing page:
what the crate is, who should use it, which sibling crate to use
instead if this isn't the right abstraction level, and links to
docs.rs and the main repository.

Add real Cargo example targets teaching irtt-client's blocking/Tokio/
managed API tiers, a basic and a multi-listener irtt-server example,
and an irtt-stats example contrasting finite vs continuous retention.
irtt-proto intentionally has none. Update each crate's top-level
rustdoc to explain its API-tier choices and point at the examples, and
enable docs.rs rustdoc example scraping for irtt-client and
irtt-server (via doc-scrape-examples on each example target, needed
because the crate has dev-dependencies).

The client examples are caller-paced against next_send_deadline()
rather than sending on every loop iteration, bound their receive wait
by the next send/timeout deadline instead of awaiting indefinitely,
poll timeouts on a short fixed cadence, and surface send/receive/close
errors instead of discarding them via unwrap_or_default()/if-let-Ok --
so they demonstrate correct pacing and terminate/report cleanly rather
than racing the server, hanging on a lost reply, or silently dropping a
failed final close.
2026-08-20 22:17:53 +07:00
Nils Andreas Svee 1ec82d8c56 docs: turn the root README into a project landing page
Reduce the root README to project discovery and navigation: what
irtt-rs is, its relationship to upstream IRTT (including a factual
capability comparison checked against upstream v0.9.2), installation,
a short quick start, and links out to detailed documentation. Detailed
CLI behavior, output/measurement semantics, continuous-mode memory
guidance, and server bind/session/negotiation semantics move into new
manpage-shaped docs under docs/ (irtt-rs, irtt-client, irtt-tui,
irtt-server), rewritten as operational guidance rather than copied
verbatim from the old README sections they replace.
2026-08-20 22:17:25 +07:00
Nils Andreas Svee e9e1ff4218 feat(server): give irtt-server sensible zero-argument defaults
With no --bind, the server now binds the wildcard IRTT port on both
address families ([::]:2112 then 0.0.0.0:2112) instead of requiring an
explicit address, on platforms where wildcard reply-source selection
is already supported. Any explicit --bind fully replaces the default
pair rather than adding to it; repeated explicit binds are unaffected.

If one of the two default listeners fails because its address family
has no local support on this host at all (IPv6 disabled on an
otherwise IPv4-capable host, for example), the server falls back to
serving just the surviving family instead of failing outright. Any
other failure reason -- the port already in use, permission denied, no
safe wildcard reply-source path -- is not a family-support problem and
is not silently worked around; startup fails as it would for an
explicit --bind. This distinction is made with the new
irtt_server::address_family_available, which actually binds the
wildcard address of the failed listener's family on an ephemeral port:
a bare socket can construct successfully even when every bind for that
family fails (Linux administratively disabling IPv6 per-interface
leaves AF_INET6 sockets constructible but every bind EADDRNOTAVAIL), so
only a real bind attempt is a reliable signal.
2026-08-20 22:17:07 +07:00
Nils Andreas Svee 2521959364 Merge pull request #127 from Lochnair/docs/release-readiness-audit-fixes
docs: implement release-readiness audit findings
2026-08-20 15:33:08 +07:00
Nils Andreas Svee 1754b77ea5 docs: scope kernel-timestamp claim to the Tokio server runtime
irtt_server::ServerCore::handle_datagram is a public API that
always passes kernel_rx_timestamp = None; only the crate-private
entry point the Tokio runtime (the irtt-server applet) uses
supplies one. Say so explicitly instead of implying every use of
this crate's server gets the preference.
2026-08-20 15:24:43 +07:00
Nils Andreas Svee cab1fd1aab docs: scope server kernel-timestamp claim to this crate's server
irtt-client can talk to any IRTT-compatible peer, not only this
repo's own irtt-server, and has no way to know what clock source
produced a peer-reported wall-clock endpoint. Restrict the
kernel-receive-timestamp-preference claim to when the peer is
this crate's own Linux irtt-server; state plainly that another
peer's reported value is used as-is otherwise.
2026-08-20 15:18:48 +07:00
Nils Andreas Svee c9269ca0ee docs: qualify RTT-unaffected claim to raw RTT only
adjusted_rtt_us/effective_rtt_us are derived in part from
server-reported processing time, which is computed from the same
server receive endpoint the client_to_server one-way-delay field
documents as kernel-preferred. Only raw RTT is guaranteed to stay
on userspace monotonic timing; say so precisely instead of
claiming RTT in general is unaffected.
2026-08-20 15:12:01 +07:00
Nils Andreas Svee 98e7231ede fix(app): gate feature-specific formatting helpers
RUSTFLAGS="-D warnings" cargo check -p irtt-rs --no-default-features
--features client (or --features tui) warned on dead code: some
format.rs helpers are only called from the client applet, others
only from the TUI. Narrowly #[cfg(feature = "...")]-gate each to
the applet that actually uses it instead of allowing the warning
or introducing a new feature, and split the tests that exercised
both groups to match.
2026-08-20 14:57:02 +07:00
Nils Andreas Svee a0b51032f2 docs: document update_targets() replacement semantics
ManagedClientHandle::update_targets() had a one-line rustdoc that
didn't say targets is a full desired-set replacement, that an
unchanged live target continues undisturbed, or that a changed
config or a terminal-generation resubmission starts a fresh
generation even for byte-identical config.
2026-08-20 14:56:56 +07:00
Nils Andreas Svee 13a907bc28 docs: document Linux kernel-timestamp preference for OWD
On Linux, one-way delay measurement best-effort prefers kernel
send/receive timestamps over userspace timing, falling back
whenever a kernel reading is unavailable or implausible. RTT is
unaffected and remains userspace-timed.

Add a user-facing README note near the measurement/output
explanation, and bring OneWayDelaySample::client_to_server's
rustdoc in line with the send-endpoint behavior, matching the
style already used for server_to_client. Also fix a stale
socket_io module doc that still claimed the captured kernel
receive timestamp went unconsumed; ServerCore has used it via
preferred_receive_wall_ns since d3f93a9.
2026-08-20 14:56:50 +07:00
Nils Andreas Svee 1ce65c0db4 docs: note that final target summaries follow CLI order
Final per-target summaries print in the order targets were
supplied on the command line, not finish order or alphabetical
label order. This only concerns the end-of-run summary printout;
it says nothing about ordering of live/interleaved per-event
output.
2026-08-20 14:56:34 +07:00
Nils Andreas Svee 90178cfb75 docs: point installation and library usage at crates.io
The README led application users through cloning the repo and
building with cargo install --path, and led library consumers
through a local path dependency, even though irtt-rs and
irtt-client are now published. Make the registry the default
path for both and keep the local-checkout/path forms documented
as the alternative for development.
2026-08-20 14:56:22 +07:00
Nils Andreas Svee 4928661467 docs: clarify testing ownership and cost 2026-08-20 14:20:49 +07:00
Nils Andreas Svee 65eb494f7a test(app): yeet even more nonsense tests dreamt up by the criminally insane 2026-08-20 14:02:47 +07:00
Nils Andreas Svee 08a9bc4e63 test(app): remove applet dispatch integration tests
This is utter nonsense dreamt up by insane clankers
2026-08-20 13:48:41 +07:00
Nils Andreas Svee 3c676edb38 Merge pull request #125 from Lochnair/docs/continuous-mode-memory
docs: describe continuous-mode memory bounds
2026-08-20 13:44:34 +07:00
Nils Andreas Svee e936d8147d docs: describe continuous-mode memory bounds 2026-08-20 13:40:21 +07:00
Nils Andreas Svee d51e154783 build(app): mirror release profile from workspace
because cargo's workspace profiles are not inherited when doing cargo install
2026-08-20 12:41:48 +07:00
Nils Andreas Svee 02bad76676 build: optimize release and distribution profiles
Remove the panic abort override and synchronize LTO settings across release and distribution profiles to ensure consistent binary optimization and debugging behavior.
2026-08-20 12:41:16 +07:00
Nils Andreas Svee 0e23017100 Merge pull request #124 from Lochnair/refactor/application-binaries
refactor(app): clarify application and binary roles
2026-08-20 12:09:10 +07:00
Nils Andreas Svee d71503e4fd fix(app): preserve featureless build support
The shared with_shutdown_flag helper, and the AtomicBool/Arc/
install_signal_handler imports it uses, only exist when at least one
applet feature (client, server, tui) is enabled. Gate them accordingly
instead of leaving them unconditionally compiled, which broke
`--no-default-features` workspace CI under -D warnings (unused
function/dead-code).

Also correct the README installation section: tui is already part of
the default feature set, so an ordinary `cargo install` already
produces irtt-tui alongside the other three binaries. Document a
lightweight `--no-default-features --features client` install instead
of the previous (incorrect) framing of tui as an opt-in extra, and fix
the same gap in the binaries/features table.
2026-08-20 12:01:07 +07:00
Nils Andreas Svee 7bea3dd54e refactor(app): clarify application and binary roles
Rename the application Cargo package from irtt-cli to irtt-rs
(crates/irtt-cli -> crates/irtt-app), and hard-rename its scriptable
client binary from irtt-cli to irtt-client, now that the application
also ships a first-class server and the old name reads as an umbrella
CLI for both roles.

Give each dedicated binary (irtt-client, irtt-tui, irtt-server) its
own entry point instead of funneling through the multicall
dispatcher's argv0/subcommand logic, so a dedicated binary only links
the applet code for the role it implements and always runs that role
regardless of the name it's invoked or copied under. irtt-rs remains
the feature-selectable multicall dispatcher and is now the only binary
that inspects argv0 or subcommands.

Move future application releases to the irtt-rs/vX.Y.Z tag namespace
(regenerated via `dist generate`), and bump the application package to
0.6.0 as a breaking distribution change. Library crate versions are
unaffected.
2026-08-20 11:44:35 +07:00
Nils Andreas Svee 7129ee0bc5 Merge pull request #123 from Lochnair/fix/cli-empty-hmac
fix(cli): reject empty HMAC keys
2026-08-20 10:48:54 +07:00
Nils Andreas Svee 75aaf68fea Merge pull request #122 from Lochnair/fix/table-target-width
fix(cli): bound target labels in table output
2026-08-20 10:48:35 +07:00
Nils Andreas Svee f470d4d7a6 Merge pull request #121 from Lochnair/fix/summary-target-order
fix(cli): preserve target order in summaries
2026-08-20 10:48:17 +07:00
Nils Andreas Svee 2cdc084ef1 fix(cli): reject empty HMAC keys
--hmac "" silently produced an empty-byte HMAC key at the protocol
boundary, so an accidentally-empty shell expansion (--hmac "$VAR")
could look like auth was deliberately enabled while actually using a
predictable empty key. Reject a zero-length --hmac value at parse
time in the client CLI, the TUI client (via the shared
CommonClientArgs), and the server CLI. Non-empty values, including
whitespace-only ones, are accepted exactly as supplied.
2026-08-20 10:43:53 +07:00
Nils Andreas Svee 5c7d75e39e fix(cli): bound target labels in table output
TABLE output formatted every column value with format!("{value:<width$}"),
which only sets a minimum width. Target is free-form/user-controlled and
is the first default table column, so a long target label pushed every
later column out of alignment while numeric/measurement columns stayed
correct.

Add a table-only truncation for Column::Target: labels within
table_width() (18) render unchanged; longer labels keep a
character-boundary-safe prefix and end with "..." so the rendered cell
is exactly 18 characters. This is scoped to format_table_cell's Target
case only, so it cannot silently shorten any measurement/data column.
CSV, TSV, and JSONL are untouched and keep emitting the full,
untruncated target label.
2026-08-20 10:42:48 +07:00
Nils Andreas Svee 6a4dce68b3 fix(cli): preserve target order in summaries
Final multi-target summaries were printed by iterating the label-keyed
BTreeMap directly, so they came out in lexicographic label order
instead of the order targets were given on the command line. Iterate
setup.targets (the canonical prepared CLI order) instead, looking up
each target's stats by label, and keep the BTreeMap only for
event-time lookup.
2026-08-20 10:42:31 +07:00
Nils Andreas Svee 0d9826800b Merge pull request #120 from Lochnair/test/session-machine-model
test(client): model SessionMachine operation sequences
2026-08-20 10:09:41 +07:00
Nils Andreas Svee 2dee315a6e Merge pull request #119 from Lochnair/test/server-set-runtime-failure
test(server): cover mid-run ServerSet listener failure
2026-08-20 10:09:19 +07:00
Nils Andreas Svee 369e166a6c Merge pull request #118 from Lochnair/test/stats-properties
test(stats): add rolling-window and IPDV property coverage
2026-08-20 10:09:01 +07:00
Nils Andreas Svee 6df16e0205 test(client): model SessionMachine open-session operation sequences
Adds a proptest-based model test that drives generated sequences of
probe sends, valid/duplicate/late/unknown/wrong-token replies, bounded
and unbounded timeout polling, peer/local close, and u32 wire-sequence
wraparound through a real SessionMachine and an independent reference
model, asserting the observable state and every emitted event match
after each operation. This combinatorial interleaving of an open
session's operations was previously untested; existing tests are all
narrow single-scenario cases.
2026-08-20 10:07:20 +07:00
Nils Andreas Svee 7c190058f4 test(server): cover mid-run ServerSet listener failure
ServerSet's startup/bind-failure paths were already covered, but nothing
exercised a listener failing after every listener had bound and was
actively serving. Inject the failure through the crate's own established
token-source test seam (the same one core::tests already uses) via a
narrow #[cfg(test)] Server::from_socket_with_core constructor, so one
listener answers a first open like its healthy sibling before its second
draw fails deterministically. Confirms ServerSet reports the failure,
shuts the sibling down, joins both tasks and releases both sockets, with
no external shutdown ever sent.
2026-08-20 10:07:19 +07:00
Nils Andreas Svee 3e2cf3bad7 test(stats): add rolling-window and IPDV property coverage
Add proptest-based generative tests for irtt-stats's stateful rolling
windows and IPDV pairing.

The rolling_count and rolling_time bounds turn out to be two
independent VecDeques (rolling.rs), not a single window bounded by
both, so the main property test proves that independence: it drives a
StatsCollector configured with both bounds simultaneously through
generated operation streams, checking after every operation that each
window matches a from-scratch reference model applying only its own
bound (and that the cumulative snapshot matches a full-history
replay). Since both the production window and the reference replay
the same events through the same deterministic aggregation code, the
comparison is exact equality, not a tolerance.

Also add two smaller property tests for IpdvTracker's bounded FIFO
state and at-most-once adjacent-pair emission under arbitrary
(including non-monotonic and duplicate) sequence streams, complementing
the existing strictly-increasing hand test.

No discrepancies between production and the reference model were
found.
2026-08-20 10:07:04 +07:00
Nils Andreas Svee a055e80fd3 Merge pull request #117 from Lochnair/test/proto-properties
test(proto): add codec property tests
2026-08-20 00:48:25 +07:00
Nils Andreas Svee 2634649852 test(proto): add codec property tests
Add proptest-based property tests for irtt-proto's varint and Params
codecs, covering the full u64/i64 domains for varint/zigzag round trips
and generated valid Params for semantic round trips, presence
omission rules, repeated-tag last-wins behavior, and unknown-tag
tolerance.
2026-08-20 00:45:16 +07:00
Nils Andreas Svee b30cf9b3a4 Merge pull request #116 from Lochnair/test/proto-fuzzing
test(proto): add decoder fuzzing
2026-08-20 00:31:40 +07:00
Nils Andreas Svee 5ce5f4fcda fix(fuzz-ci): build cargo-fuzz with stable, not the nightly override
Installing cargo-fuzz under a nightly directory override made rustix's
build script (a cargo-fuzz dependency) gate on `rustc_attrs`, which this
runner's nightly rejects as reserved-for-rustc. Install nightly without
overriding the directory default so `cargo install cargo-fuzz` still
builds under stable; only `fuzz build`/`fuzz run` opt into `+nightly`.
2026-08-19 23:36:08 +07:00
Nils Andreas Svee 719073df1c test(proto): add decoder fuzzing
Add isolated cargo-fuzz/libFuzzer infrastructure for irtt-proto, fuzzing
decode_request, decode_open_reply, decode_echo_reply, Params::decode(_with_presence),
and the varint codec against arbitrary and structurally-derived wire input.
Each target's core invariant is that arbitrary bytes must never panic; a
small clean-room seed corpus (generated from irtt-proto's own encoders) and
a bounded nightly smoke CI workflow are included. The fuzz project is its
own isolated cargo workspace and does not affect the root workspace, its
lockfile, or any published crate's dependency graph.
2026-08-19 23:33:27 +07:00
Nils Andreas Svee 9b70bd8b9d Merge pull request #113 from Lochnair/renovate/lock-file-maintenance
chore(deps): maintain cargo lockfile
2026-08-17 23:16:51 +07:00
renovate[bot] 7b7cb6042e chore(deps): maintain cargo lockfile 2026-08-17 02:55:09 +00:00
Nils Andreas Svee 444ec3dd6a Merge pull request #115 from Lochnair/fix/break-client-server-dev-dep-cycle
fix(tests): break the irtt-client/irtt-server dev-dependency cycle
2026-08-17 09:53:49 +07:00
Nils Andreas Svee 3f1ac88989 fix(deny): add a license field to irtt-integration-tests
cargo-deny's licenses check requires a license expression even on an
unpublished crate; add the workspace's Apache-2.0 license so the check
passes rather than treating irtt-integration-tests as unlicensed.
2026-08-17 09:51:03 +07:00
Nils Andreas Svee 1753e91d5f fix(tests): break the irtt-client/irtt-server dev-dependency cycle
irtt-client and irtt-server mutually dev-depended on each other for
cross-crate integration tests. That cycle blocks `cargo publish` for
either crate whenever the other doesn't already exist on the registry
at the required version, which is exactly the situation for a crate's
first-ever release (irtt-server has never been published before).

Introduce a new `irtt-integration-tests` crate (publish = false, no
library target) to own tests whose actual subject is the interaction
between two or more irtt-* production crates:

  crate-local behavior          -> crate's own tests/
  cross-crate/system behavior   -> irtt-integration-tests

Move every test that imports both irtt_client and irtt_server there:
the client-side support harness (hmac, large_packets, protocol_options,
fill_negotiation) and the server-side real-socket tests that drive a
real AsyncClient against a real Server (async_client, negotiation_policy,
fill_wire_bytes, traffic_class, wildcard_source). server_set.rs stays in
irtt-server/tests since it only uses irtt_proto as test machinery to
drive irtt-server directly, which is a normal one-directional test
dependency, not a peer relationship.

irtt-server's dev-dependency on irtt-client is dropped entirely now
that nothing under crates/irtt-server/tests needs it, which fully
breaks the cycle from that side.

irtt-client keeps a one-directional dev-dependency on irtt-server: a
handful of white-box unit tests in src/async_client/tests.rs assert on
private AsyncClient internals (socket, remote, test_hooks) while also
needing a real in-tree server for blocking/async equivalence checks, so
they can't move to an external crate without losing that access. Their
InTreeServer helper is duplicated locally rather than shared, matching
the existing precedent of irtt-cli's own tests/support/in_tree_server.rs.
2026-08-17 09:49:32 +07:00
Nils Andreas Svee e120cca9bc Merge pull request #114 from Lochnair/chore/independent-crate-versioning
chore(release): independent per-crate versioning, first 0.5.0 releases
2026-08-17 09:32:39 +07:00
Nils Andreas Svee d66b5ed8de chore(release): move to independent per-crate versioning at 0.5.0
Each workspace crate now owns its own version instead of the previous
lockstep scheme; irtt-proto, irtt-client, irtt-server, irtt-stats, and
irtt-cli all become 0.5.0 as the first independently-versioned release.
Workspace dependency requirements are bumped to match.

Add a root CHANGELOG.md with a real per-crate 0.5.0 section drawn from
archaeology of every merge since v0.4.0, not just path-filtered commits.

Move cargo-dist to a crate-qualified `irtt-cli/vX.Y.Z` tag namespace so
only irtt-cli (the one dist-able application) triggers a GitHub Release;
library crate tags (irtt-proto/irtt-client/irtt-server/irtt-stats) exist
purely to pin the source for a crates.io release and no longer risk
tripping the release workflow. Regenerate the renamed
irtt-cli-release.yml via `dist generate` and update the few other files
that referenced the old release.yml path by name. Also fix a stale
`features = ["full"]` in dist-workspace.toml left over from a feature-flag
refactor that dropped the "full" feature entirely, which was silently
breaking every dist build.

Rescope semver.yml's tag trigger to the same five crate-qualified tags
so semver checks keep running under the new convention.
2026-08-17 09:29:25 +07:00
renovate[bot] 6722093120 chore(deps): update github actions to ff2f1c6 2026-08-16 17:35:43 +00:00
Nils Andreas Svee 046e1f52f1 Merge pull request #111 from Lochnair/fix/linux-receive-eintr
fix(io): retry interrupted receives
2026-08-17 00:35:19 +07:00
Nils Andreas Svee a598dbdc5c fix(io): address review feedback on interrupted-receive retries
- Error-queue drain: an interrupted MSG_ERRQUEUE read no longer ends
  the bounded drain early as if the queue were empty (which could
  strand a still-queued TX timestamp past the point its probe is
  looked up and removed). It now reports ErrorQueueRecord::Ignored,
  consuming one attempt of the drain's existing per-call work budget
  and retrying the same slot instead.
- Fake-server recv_from test helpers (client_multi_target.rs and
  tests/support/mod.rs) now keep one absolute deadline across
  interruptions, re-applying only the remaining time on each retry
  and restoring the configured timeout afterward, mirroring the
  production blocking-receive fix these tests exist to guard rather
  than restarting the full timeout on every interruption.
2026-08-17 00:32:37 +07:00
Nils Andreas Svee ca2389174f fix(io): retry interrupted receives
Real signal delivery on Linux (observed via x86_64-musl workspace
testing) can interrupt blocking and nonblocking recv/recvmsg calls
with EINTR. Several production and test receive paths treated that as
a fatal error rather than a transient interruption:

- Client::open's blocking receive loop and Client::recv_once/
  recv_available's blocking receive returned ClientError::Socket on
  Interrupted instead of retrying within their existing timeout
  budget.
- AsyncClient::poll_open and poll_recv's nonblocking recvmsg path
  (Linux ancillary) failed the same way instead of retrying through
  the existing readiness/work-budget machinery.
- The server's ancillary receive loop could terminate the listener on
  an interrupted nonblocking recvmsg.
- The client's MSG_ERRQUEUE TX-timestamp drain turned an interrupted
  read of optional metadata into a fatal ClientError::Socket.
- Fake-server test helpers panicked or misclassified EINTR as a
  timeout instead of retrying it.

Interrupted is now retried transparently everywhere a receive can
observe it, while preserving each path's existing timing contract:

- Blocking Open reuses its already-absolute per-attempt deadline, so
  interruptions resume within the same attempt without retransmitting
  or extending the deadline.
- recv_once/recv_available compute one absolute deadline per logical
  receive (when a timeout is configured) so repeated interruptions
  cannot extend it past the configured timeout, and restore the
  socket's configured read timeout afterward so a later call in the
  same recv_available budget is unaffected.
- AsyncClient's poll_open already bounds its retry loop with
  OPEN_POLL_WORK_BUDGET; poll_recv gains an equivalent
  RECV_POLL_WORK_BUDGET, since unlike WouldBlock, Interrupted does not
  clear Tokio's readiness state, so an unbounded retry loop was
  otherwise possible under sustained signal delivery.
- The server's receive loop retries Interrupted the same way it
  already retries WouldBlock, yielding to the runtime after a bounded
  number of consecutive interruptions for the same reason.
- The error-queue drain treats an interrupted read as no record
  obtained this drain (deferring to the next natural drain point)
  rather than a socket error, matching its existing EWOULDBLOCK
  handling.

Deterministic regression tests use a small test-only injection hook
(mirroring existing send-injection test hooks) rather than real signal
timing, covering: Open completing despite interruptions with no
retransmit, Open's deadline not extending under repeated
interruptions, recv_once classifying a reply normally despite
interruptions, recv_once's timeout not extending under repeated
interruptions, and the AsyncClient equivalents for open and recv.

No protocol, session, or timeout-classification behavior changes; no
new dependencies; send-side EINTR handling is unchanged and out of
scope.
2026-08-17 00:23:24 +07:00
Nils Andreas Svee e3815fdeed Merge pull request #110 from Lochnair/fix/server-i686-timespec-test
test(server): use native timespec bounds
2026-08-16 22:06:29 +07:00
Nils Andreas Svee 0793a7e1c5 test(server): use native timespec bounds 2026-08-16 22:03:03 +07:00
Nils Andreas Svee c1d75ba4cc Merge pull request #109 from Lochnair/fix/managed-terminal-reconcile
fix(managed): restart resubmitted terminal targets
2026-08-15 22:56:44 +07:00
Nils Andreas Svee 1d799c4ca0 fix(managed): restart resubmitted terminal targets 2026-08-15 22:50:19 +07:00
Nils Andreas Svee 00bba12746 Merge pull request #108 from Lochnair/refactor/positional-target-labels
refactor(cli): unify target argument syntax
2026-08-15 21:08:07 +07:00
Nils Andreas Svee a03004b22f refactor(cli): unify target argument syntax 2026-08-15 20:56:12 +07:00
Nils Andreas Svee 79d3b9350c Merge pull request #107 from Lochnair/fix/client-target-output
fix(cli): preserve target identity in default output
2026-08-15 20:25:55 +07:00
Nils Andreas Svee e4e6d63ef1 fix(cli): preserve target identity in default output 2026-08-15 20:21:04 +07:00
Nils Andreas Svee 6d7adf52a7 Merge pull request #106 from Lochnair/fix/client-post-send-timestamp
fix(client): timestamp probes after successful send
2026-08-15 20:02:51 +07:00
Nils Andreas Svee aac5247f01 fix(stats): clamp loss windowing timestamp to its own sent_at
timeout_at is pre-send anchored (F-04), so it can precede the same
probe's post-send sent_at when probe_timeout is small relative to the
send-call duration. Rolling-window eviction assumes non-decreasing
event timestamps, so clamp the internal StatsEvent::Loss windowing
value without changing the public EchoLoss.timeout_at field. Also
correct an AGENTS.md claim that sent_at feeds the downstream one-way
delay, which it does not.
2026-08-15 19:46:19 +07:00
Nils Andreas Svee e7b0632239 docs(client): correct IPDV claim in AGENTS.md
The post-send sent_at change does shift the upstream IPDV component's
endpoint, since ReplySample copies sent_at into client_send_mono/wall
for send_ipdv_ns. Document the shift instead of claiming IPDV is
unaffected.
2026-08-15 19:33:36 +07:00
Nils Andreas Svee a3896376da fix(client): timestamp probes after successful send
Moves the client userspace probe sent_at measurement to immediately
after a successful UDP send, improving raw RTT and the userspace
upstream-OWD fallback by excluding the socket send-call interval and
the small amount of local work preceding it.

All fallible probe-commit preflight (timeout deadline checked_add,
capacity/collision checks) still runs before the send, from a private
pre-send anchor (SessionMachine::finalize_probe_commit). The public
sent_at is now supplied afterward to an infallible
SessionMachine::commit_probe_sent, so DurationOverflow still guarantees
nothing was transmitted and a WouldBlock try_send still commits
nothing.

The operational probe timeout deadline stays anchored to the pre-send
timestamp on purpose, not to the new post-send sent_at, preserving
existing timeout/loss timing. Linux kernel TX_SOFTWARE plausibility
keeps its own pre-send wall-clock lower bound
(PendingProbe::tx_not_before_wall) instead of the post-send sent_at,
since a legitimate TX_SOFTWARE timestamp can be generated while the
send syscall is still in flight and would otherwise be wrongly
rejected.

Also flags (without touching production code) that irtt-stats'
ReplySample::from_reply_parts feeds the public sent_at directly into
IpdvSample.client_send_mono/client_send_wall_ns, so the upstream/send
IPDV component naturally shifts along with the sent_at semantics
change; per scope this is reported rather than compensated for.
2026-08-15 19:26:57 +07:00
Nils Andreas Svee 0ca36f2192 Merge pull request #105 from Lochnair/fix/client-kernel-tx-owd
fix(client): use kernel transmit time for upstream OWD
2026-08-15 19:01:13 +07:00
Nils Andreas Svee 1066d5df72 fix(client): use kernel transmit time for upstream OWD
Upstream one-way delay now prefers the correlated Linux TX_SOFTWARE wall
timestamp retained by PendingProbe when it is locally plausible (sent_at
<= kernel_tx <= received_at, all on the client's own wall clock), falling
back to the userspace sent_at.wall sample otherwise. RTT, downstream OWD,
IPDV, and timeout/scheduling semantics are unchanged.
2026-08-15 18:54:41 +07:00
Nils Andreas Svee 1dc18588e7 Merge pull request #104 from Lochnair/feat/client-kernel-tx-capture
feat(client): capture kernel transmit timestamps
2026-08-15 18:41:37 +07:00
Nils Andreas Svee 9e3477e617 feat(client): capture kernel transmit timestamps
On Linux, best-effort upgrade the socket's SO_TIMESTAMPING flags to
TX_SOFTWARE | OPT_ID | OPT_TSONLY after a successful Open and before the
first Echo probe. Promote the #103 error-queue foundation to production:
MSG_ERRQUEUE is drained opportunistically with a fixed per-call budget at
several natural choke points (after a probe send, before processing a
reply, before evaluating timeouts), and each automatic OPT_ID timestamp is
correlated directly with wire_seq — safe because Linux rolls back its
speculative sk_tskey increment on every send failure, wire_seq and the
kernel counter start together at every Open, and a socket can never be
reopened for a second session. The observed kernel TX wall timestamp is
retained on PendingProbe (surviving pending -> timed-out retention) as
dormant metadata only; RTT, OWD, and IPDV are unchanged and continue to use
the userspace send timestamp exclusively. Capability failure is fully
non-fatal and RX timestamping is unaffected either way. Non-Linux and
non-ancillary builds are unchanged.
2026-08-15 15:58:48 +07:00
Nils Andreas Svee 504d290bd2 Merge pull request #103 from Lochnair/refactor/client-linux-timestamping
refactor(client): unify Linux software timestamping
2026-08-15 15:13:42 +07:00
Nils Andreas Svee 12b4816e14 fix(client): align the error-queue control buffer
The MSG_ERRQUEUE control buffer was a plain [u8; N], which only
guarantees byte alignment. recvmsg's cmsgs() walks cmsghdr references
out of that storage and requires cmsghdr alignment, so this mirrors
the parent module's #[repr(align(8))] ControlBuffer instead of relying
on incidental stack alignment.
2026-08-15 15:08:30 +07:00
Nils Andreas Svee 97b094539f refactor(client): unify Linux software timestamping
Migrate the Linux client's receive kernel timing from SO_TIMESTAMPNS to
SO_TIMESTAMPING with RX_SOFTWARE + SOFTWARE, reported via
SCM_TIMESTAMPING instead of SCM_TIMESTAMPNS. Linux documents fallback
quirks when SO_TIMESTAMP[NS] and SO_TIMESTAMPING are both enabled on a
socket, so this establishes one unambiguous software-timestamp facility
before client TX timestamp work builds on the same API.
ReceiveMeta.kernel_rx_timestamp semantics, PacketMeta propagation, and
all PR #100 downstream OWD/RTT/IPDV behavior are unchanged. The receive
control buffer is now sized deliberately from cmsg_space::<T>() instead
of an unexplained fixed constant.

Also add safe, test-only Linux primitives that a follow-up change needs
for client TX timestamping: SO_TIMESTAMPING TX flag construction,
nonblocking MSG_ERRQUEUE parsing, and extended-error classification
that distinguishes a genuine send-timestamp completion from a malformed
timestamp record and from a real socket/network error. Production
sockets do not enable SOF_TIMESTAMPING_TX_SOFTWARE and nothing drains
the error queue in production; these primitives only run under
#[cfg(test)].
2026-08-15 14:57:21 +07:00
Nils Andreas Svee 10e0a38a48 Merge pull request #102 from Lochnair/fix/server-kernel-rx-wall
fix(server): use kernel receive time for wall timestamps
2026-08-15 14:10:52 +07:00
Nils Andreas Svee d3f93a9370 fix(server): use kernel receive time for wall timestamps
An echo reply's receive wall timestamp now prefers the kernel arrival
reading the Linux listener captured, where that reading is plausible for
the datagram it describes. That removes socket-queue and runtime wakeup
latency from the upstream one-way delay a client computes, and it is the
only thing the observation is allowed to change.

The runtime reaches the core through a crate-private entry point taking
the optional observation as an explicit per-datagram argument; the public
ServerCore::handle_datagram is unchanged in signature and in behavior, and
both run one implementation. There is no "next receive timestamp" side
channel and no clock wrapper.

ClockSample stays one paired userspace instant: the preferred wall value
is carried beside it on the classified echo, never inside it. Lifecycle,
rate and maximum-duration policy remain on received_at.mono_ns, midpoints
remain the mean of the paired userspace samples, and no monotonic instant
is estimated from a wall reading. A reading that does not convert, that is
later than the core's own sample, or that lags it by more than one second
falls back to the userspace wall; it is never a reason to drop a datagram.
2026-08-15 13:46:29 +07:00
Nils Andreas Svee e6491a1f70 Merge pull request #101 from Lochnair/feat/server-kernel-rx-capture
feat(server): capture kernel receive timestamps
2026-08-15 13:18:55 +07:00
Nils Andreas Svee 1b0fc2cabe feat(server): capture kernel receive timestamps 2026-08-15 13:13:07 +07:00
Nils Andreas Svee c80edb460b Merge pull request #100 from Lochnair/fix/client-kernel-rx-owd
fix(client): use kernel receive time for downstream OWD
2026-08-15 12:53:41 +07:00
Nils Andreas Svee c28929bb78 fix(client): use kernel receive time for downstream OWD 2026-08-15 12:44:16 +07:00
Nils Andreas Svee 277fbfcb57 Merge pull request #99 from Lochnair/refactor/server-receive-drop-policy
refactor(server): centralize receive drop policy
2026-08-15 11:33:43 +07:00
Nils Andreas Svee 435315a8c0 test(server): assert oversized receives never reach the core 2026-08-15 11:30:53 +07:00
Nils Andreas Svee 7df85767ad refactor(server): centralize receive drop policy 2026-08-15 11:24:30 +07:00
Nils Andreas Svee ce462fd37b Merge pull request #98 from Lochnair/refactor/stats-exact-medians
refactor(stats): retain exact finite timing samples
2026-08-15 11:13:37 +07:00
Nils Andreas Svee 71b5819366 docs(stats): scope the exact-median contract to the cumulative snapshot
Rolling snapshots are recomputed with `SampleMode::RunningOnly` regardless
of the configured sample mode, so the new `SampleMode::Exact` wording — that
every timing metric a `Snapshot` exposes reports a median — was not true of
`rolling_count()` and `rolling_time()`.

Scope the promise to the cumulative snapshot and say plainly on both rolling
accessors that they use running statistics only and report no median, since
a rolling window is a bounded recent view rather than the run's retained
history. A test pins both halves so the wording cannot quietly go stale.

Rolling retention behavior itself is unchanged.
2026-08-15 10:48:45 +07:00
Nils Andreas Svee db9bd515bb refactor(stats): retain exact finite timing samples
`SampleMode::Exact` retained exact samples for eight of the eleven public
timing metrics. Send call, timer error, and server processing always built
their `TimeMetric` with retention disabled, so a finite run reported no
median for them even though they are the same public `TimeStats` type and
appear in the same summary table with a Median column.

Derive one `retain_exact` decision from the sample mode in `CoreStats::new`
and apply it to every timing metric, so exact mode means the same thing for
all of them. Running-only mode is unchanged and still retains none of them.

The retention estimator counted eight exact sample streams per measurable
reply. Rename it to the eleven exact samples an ordinary successful probe
can retain and document the wider optional-measurement upper bound, which
raises the finite estimate on this target from 668 to 764 bytes per probe.
A test counts the metrics a fully measured probe actually retains and
compares it against that constant, so retention and its estimate cannot
drift apart unnoticed.

No new median-capability type was needed: `TimeStats::median_ns` already
distinguishes a retained median from an absent one, and an optional
measurement the server never supplied still produces no samples at all.
2026-08-15 10:41:17 +07:00
Nils Andreas Svee 76dc904da1 Merge pull request #97 from Lochnair/refactor/stats-retention-estimate
refactor(stats): own retention memory estimates
2026-08-15 10:30:44 +07:00
Nils Andreas Svee b93e6d0ab7 refactor(stats): own retention memory estimates 2026-08-15 10:25:04 +07:00
Nils Andreas Svee 44026068cf Merge pull request #96 from Lochnair/test/client-driver-conformance
test(client): expand blocking and async conformance coverage
2026-08-15 10:11:37 +07:00
Nils Andreas Svee 0c3acce36a Merge pull request #95 from Lochnair/refactor/managed-target-locality
refactor(client): centralize managed target accounting
2026-08-15 10:06:18 +07:00
Nils Andreas Svee 006add3648 Merge pull request #94 from Lochnair/refactor/shared-target-preparation
refactor(cli): centralize managed target preparation
2026-08-15 10:05:43 +07:00
Nils Andreas Svee 50e4633c7a refactor(cli): centralize managed target preparation
Both client applets duplicated the step from parsed arguments to a runnable
managed setup: target specs, managed target preparation, a ClientConfig
built from an arbitrary primary target, pacing conversion, and an identical
ManagedClientConfig skeleton with its own copy of the event-capacity
constant. The primary-target step was the worst of it, since the managed
driver replaces server_addr per target, so the address the applets went out
of their way to find was immediately discarded.

shared::client::prepare now performs that step once: it validates the target
set first, then returns the validated targets, one target-free ClientConfig
template, and the pacing, with managed_config()/managed_targets() for the
driver. CommonClientArgs::to_client_config no longer takes a server address.

Applet-specific semantics are unchanged: each command keeps its own duration
default and help text, the client still accepts no target for
--list-columns, and the TUI still rejects an empty target list at parse
time.
2026-08-15 10:04:08 +07:00
Nils Andreas Svee c2666d776e Merge pull request #93 from Lochnair/test/tui-rendering
test(tui): add off-screen render coverage
2026-08-15 09:55:09 +07:00
Nils Andreas Svee ed8d212a17 Merge pull request #92 from Lochnair/refactor/stats-late-reply-policy
refactor(stats): make late-reply sampling explicit
2026-08-15 09:50:01 +07:00
Nils Andreas Svee 61fdff5c98 Merge pull request #91 from Lochnair/refactor/cli-scalar-formatting
refactor(cli): unify scalar presentation formatting
2026-08-15 09:49:50 +07:00
Nils Andreas Svee c1a8d70931 Merge pull request #90 from Lochnair/refactor/exhaustive-client-failures
refactor(client): make managed failure classification exhaustive
2026-08-15 09:49:37 +07:00
Nils Andreas Svee 17ade8e6cb test(tui): scope render assertions to their owning panels
The frame echoes most panel content elsewhere - the session token appears in
recent events, a replying target names a chart series, and the sample
panel's last-warning row satisfies a loose warning search - so whole-frame
assertions kept claiming coverage they did not have.

Panels are now addressed structurally: has_panel matches a title in a top
border, panel_rows slices a panel's interior to its own columns, and
assertions run against the owning panel. That fixes the session token,
recent-events warning line, graph-view panel exclusions and graph-view
target rows, and converts the remaining whole-frame checks in the module so
the class of defect does not come back.

Each tightened assertion was confirmed to fail against a deliberate
regression of the behavior it protects.
2026-08-15 02:12:23 +07:00
Nils Andreas Svee c12617a669 test(tui): tighten render assertions to the panels they claim
Four assertions did not protect what they described. Target labels were
searched across the whole frame, where the first target also appears in the
session header and a replying target appears in recent events and the graph
legend; labels are now matched against the target table's own columns, with
the status column and the replying target's last sample. The sample panel's
effective RTT is now asserted as a whole row, since the fixture's adjusted
and effective values coincide and a bare "2.5ms" still matched with the
effective field dropped. The minimum-height case now rejects dashboard
content like the minimum-width case does, and the compact layout checks for
the timing panel title rather than one incidental run of spaces from the
large timing formatter.

Each tightened assertion was confirmed to fail against a deliberate
regression of the behavior it protects.
2026-08-15 02:01:18 +07:00
Nils Andreas Svee e1bdd7920e test(client): compare full event streams before zipping
The zip loops stopped at the shorter list, and the sequence helpers filter
out everything that is not a probe or reply, so a driver emitting an extra
trailing event of another kind would still have passed. Event-list lengths
are now asserted before the element-wise comparison.
2026-08-15 01:54:54 +07:00
Nils Andreas Svee dd68608d56 refactor(client): correct late-reply docs and drop private counter tests
The counter tests instantiated the private TargetCounters and mirrored its
match arms, duplicating outcome-level coverage that already asserts
replies_received and late through ManagedTargetOutcome, and coupling a
private refactor to persistent test churn.

The new counter documentation also defined late replies in timeout-only
terms. A reply that arrives behind an already-received sequence is
classified late by process_echo_reply even when its own probe has not timed
out, so both counters are now described in terms of order and timeout.
2026-08-15 01:49:25 +07:00
Nils Andreas Svee 2062535c20 test(tui): assert the warning on its sample-panel row
The whole-frame substring assertions passed as long as the warning appeared
anywhere, so a sample panel regressing to "last warning: -" would still have
been reported as covered. The assertion now locates the last-warning row and
requires it to carry the warning itself.
2026-08-15 01:49:23 +07:00
Nils Andreas Svee 665f839333 test(client): drop the coverage-only classification enumeration
The removed test only checked that every failure kind appeared somewhere in
the same hand-maintained case table the mapping test already walks. It could
not prove exhaustiveness or the absence of a fallback, which the compiler
enforces, and it pinned the incidental fact that each category currently has
an input.
2026-08-15 01:49:22 +07:00
Nils Andreas Svee 150ee4fd3b test(client): expand blocking and async conformance coverage
The existing conformance suite compared the two driver styles on open
negotiation with HMAC and DSCP, no-test completion, and peer close. It said
nothing about failed-open rollback, multi-probe scheduling, or the errors
each driver promises for out-of-state operations.

Three tests close those gaps against externally observable behavior only:
a first open that times out against a deliberately mute peer, followed by a
retry that opens, probes, and closes with a fresh sequence; three
caller-paced probes against a real irtt-server, matched sequence for
sequence in both drivers; and equivalent errors for probing before open,
opening twice, and probing or closing after close.

No production code changed, and no divergence between the drivers was
found.
2026-08-15 01:41:09 +07:00
Nils Andreas Svee 5506220f67 refactor(client): centralize managed target accounting
Target-local accounting was spread across the managed driver: the event
counter increments were inline in publish_client_events, the authoritative
packets_sent was copied from the client at fifteen separate call sites, and
ManagedTargetOutcome was constructed twice from the same six fields.

TargetCounters::observe now owns the per-event accounting,
TargetRuntime::sync_packets_sent/record_packets_sent own the authoritative
sent count, and TargetRuntime::outcome owns outcome construction, replacing
the free target_outcome function and the inline copy in finish_target. No
counter is touched outside TargetRuntime any more.

The replies_received semantics are unchanged and now documented on both the
private counter and the public outcome field: on-time unique echo replies
only, with late unique replies counted by late. observe matches ClientEvent
exhaustively, so a new event variant must be classified deliberately.
2026-08-15 01:36:04 +07:00
Nils Andreas Svee 17d3fed7c5 test(tui): add off-screen render coverage
The TUI had pure state and graph-viewport tests but nothing that rendered a
frame, so layout selection and panel presence were unprotected.

Eleven tests drive draw_dashboard through Ratatui's TestBackend across the
durable layouts: below the minimum width and height, the smallest usable
terminal, compact, large, multi-target, and the graph view in both target
modes, including active sample data, warning and error presentation, and
status-line state. Assertions are on visible text and layout facts rather
than full-screen snapshots.

Test-only change; production rendering is untouched.
2026-08-15 01:24:16 +07:00
Nils Andreas Svee e0dec9ac5f refactor(stats): make late-reply sampling explicit
The TUI suppressed late-reply timing by rebuilding the event with sent_at,
rtt, server_timing, one_way and received_stats erased. That leaked stats
policy into event construction and, as a side effect, reclassified a matched
late reply as an untracked late reply.

StatsConfig now carries an explicit LateReplyMode. Measure keeps today's
behavior for every existing caller. CountOnly keeps the reply a late unique
reply and keeps packet, byte and late counters updating, while suppressing
timing, one-way, server-processing and IPDV contributions and reporting
contributed_sample == false. Cumulative and rolling snapshots share the
policy. Genuinely untracked late replies stay untracked in both modes.

The TUI selects CountOnly and passes the original event straight to the
collector.
2026-08-15 01:17:46 +07:00
Nils Andreas Svee 69b90dd64a refactor(cli): unify scalar presentation formatting
Stream output, the run summary, and the TUI each carried their own copy of
the same scalar vocabulary, and they had drifted: the TUI spelled
microseconds "us" while the others used "µs", percentages guarded
non-finite values in one place but not another, and the absent-value
placeholder was spelled inline everywhere.

cmd::format now owns nanosecond-scalar, percentage, and count formatting
with one documented unit-selection and rounding policy, µs everywhere, and
signed values preserved. The frontends keep their own metric sets and
layouts; the TUI keeps its coarse wall-clock span policy, now named
format_span and documented as deliberately distinct, and uses the shared
scalar vocabulary below one second.
2026-08-15 01:11:33 +07:00
Nils Andreas Svee e3003c348f refactor(client): make managed failure classification exhaustive
classify_client_error now matches every ClientError variant explicitly, so a
new variant fails to compile here until its real classification is chosen.
NoTokioRuntime classifies as InvalidState and DatagramLengthMismatch as
Socket; ManagedTargetFailureKind::Other survives only as a deliberate
category with no default path into it.

ManagedTargetFailurePhase and ManagedTargetFailureKind gain controlled
Display impls, and the CLI client/TUI failure diagnostics use them instead
of leaking Debug enum spelling into user-facing text.
2026-08-15 01:03:35 +07:00
Nils Andreas Svee 90c04b6569 refactor(cli): simplify feature flags and make stats mandatory for clients
Remove the optional "stats" feature by merging it into the "client" and "tui" features to reduce conditional compilation complexity. Replace the "client-runtime" internal feature with direct dependency sets.
2026-08-15 00:24:28 +07:00
Nils Andreas Svee 88419d4144 build(cli): enable ancillary feature for client-runtime
Add the ancillary feature dependency to client-runtime to support advanced socket operations and metadata handling.
2026-08-14 22:23:33 +07:00
Nils Andreas Svee 25616969c4 Merge pull request #89 from Lochnair/test/real-server-peers
test: prefer real server for compliant peers
2026-08-14 18:25:24 +07:00
Nils Andreas Svee 446028671b test(cli): use real server for compliant peers 2026-08-14 17:21:22 +07:00
Nils Andreas Svee fca7a2fcc6 test(client): use real server for compliant peers 2026-08-14 17:17:41 +07:00
Nils Andreas Svee cbef62be7a Merge pull request #88 from Lochnair/feat/server-multi-listener
feat(server): add multi-listener server set
2026-08-14 16:46:45 +07:00
Nils Andreas Svee 2171d02203 feat(cli): route server through server set
`--bind` becomes repeatable, so one process can serve several addresses:
an explicit IPv4 and IPv6 pair on one port, or two interface addresses
that must stay distinct.

The applet now builds a `ServerSet` for every invocation, including a
single bind. There is deliberately no bind-count branch and no direct
path from the CLI to `Server`: one orchestration path means startup
errors, shutdown and listener-failure handling are identical whatever was
configured, and the single-listener case — the common one — keeps that
path exercised in normal use rather than only in tests.

The configuration stays one `ServerConfig`; addresses never enter it, and
`ServerSet` clones it into each listener. Bounds in it are therefore per
listener, which `--max-sessions` help now says. Listener lines are
printed only once the whole set is up, so a set whose second bind fails
no longer announces the first as listening.

The shutdown flag, its polling future and the current-thread runtime are
unchanged: exactly one shutdown future is created and the set fans it out
internally.
2026-08-14 16:34:02 +07:00
Nils Andreas Svee 09f0d6e02e feat(server): add multi-listener server set
`Server` answers on one address. A host that must serve several — an
explicit IPv4 and IPv6 pair, or two interface addresses — needs several
of them, and something has to own their construction, their shutdown and
each other's failures.

`ServerSet` is that owner, and deliberately not a protocol object: it
holds no session state, no tokens and no clock, and nothing it does is
visible on the wire. Each listener stays a complete `Server` with its own
socket, core, session table and tokens, so a token issued by one is
unknown at the others and every bound in the cloned `ServerConfig` —
`max_sessions` included — applies per listener rather than per process.

Construction binds every listener or none, preserving requested order and
recording the addresses actually bound. `run` moves each listener into
its own task, fans one shutdown future out over a watch channel, and
joins them all: a listener that fails, or that returns before shutdown
was asked for, shuts its siblings down and fails the set, because a
service configured for two families must not continue silently as one.

A genuine IPv6 listener sharing an explicit port with an IPv4 sibling is
created `IPV6_V6ONLY` before it is bound, since a dual-stack socket would
otherwise claim the IPv4 port too. That is the only pre-bind step, it
uses safe `socket2` APIs, and an IPv4-mapped address is read as the IPv4
listener it is — never as the IPv6 half of a pair. Standalone
`Server::bind` semantics are unchanged.
2026-08-14 16:04:46 +07:00
Nils Andreas Svee fabd698187 Merge pull request #87 from Lochnair/feat/server-negotiation-policy
feat(server): add negotiation policy controls
2026-08-14 15:06:45 +07:00
Nils Andreas Svee 8607720219 docs(server): describe single allowance as one instant 2026-08-14 15:00:38 +07:00
Nils Andreas Svee 5be224d1b2 feat(cli): expose server negotiation policy 2026-08-14 14:30:28 +07:00
Nils Andreas Svee 8308c077eb feat(server): add negotiation policy controls 2026-08-14 14:24:57 +07:00
Nils Andreas Svee 58c04e8d04 Merge pull request #86 from Lochnair/feat/server-fill-policy
feat(server): implement server fill
2026-08-14 13:54:16 +07:00
Nils Andreas Svee a471fb3bfd docs: distinguish no fill preference from none 2026-08-14 13:07:39 +07:00
Nils Andreas Svee e4239f45c5 feat(server): generate echo payload fill 2026-08-14 12:58:30 +07:00
Nils Andreas Svee 36500e1ea0 feat(server): negotiate server fill 2026-08-14 12:32:37 +07:00
Nils Andreas Svee 24465bc1da Merge pull request #85 from Lochnair/feat/server-wildcard-source-selection
feat(server): pin wildcard reply source address
2026-08-14 12:11:17 +07:00
Nils Andreas Svee aed7eef454 fix(server): mark a mapped listener with the IPv4 option 2026-08-14 11:49:05 +07:00
Nils Andreas Svee 2d88939ed4 fix(server): pin the reply source, not the route 2026-08-14 11:31:10 +07:00
Nils Andreas Svee e154508cb1 fix(server): treat a mapped wildcard bind as wildcard 2026-08-14 00:50:25 +07:00
Nils Andreas Svee 3c554a78d7 test(server): verify wildcard source selection 2026-08-14 00:30:02 +07:00
Nils Andreas Svee 265b857b84 feat(server): pin wildcard reply source 2026-08-14 00:20:17 +07:00
Nils Andreas Svee 047a842f2d Merge pull request #84 from Lochnair/feat/server-reply-traffic-class
feat(server): apply reply traffic class
2026-08-13 23:12:59 +07:00
Nils Andreas Svee 965369ae2a docs(server): drop a private link from public docs 2026-08-13 22:30:54 +07:00
Nils Andreas Svee 1830429ce5 fix(server): keep unmarkable listeners serving 2026-08-13 22:26:58 +07:00
Nils Andreas Svee 5420387846 feat(server): apply reply traffic class 2026-08-13 20:40:07 +07:00
Nils Andreas Svee a4a5219b9e feat(server): carry reply traffic class 2026-08-13 20:34:42 +07:00
Nils Andreas Svee 68e7d18c12 Merge pull request #83 from Lochnair/feat/cli-server-applet
feat(cli): add server applet
2026-08-13 19:49:54 +07:00
Nils Andreas Svee 565d7fb163 fix(cli): ignore the executable suffix in argv0 dispatch 2026-08-13 19:17:20 +07:00
Nils Andreas Svee a672f78e86 feat(cli): add server applet 2026-08-13 19:01:39 +07:00
Nils Andreas Svee ae23e410bb Merge pull request #82 from Lochnair/test/upstream-client-interop
test(server): validate upstream 0.9.1 client interop
2026-08-13 18:19:43 +07:00
Nils Andreas Svee 9d7566955d docs(server): drop the unobserved continuous-mode wire claim
The continuous-request row said a -d 0 request never reaches the wire,
which no observation in this validation supports: the run took no packet
capture and the server was not instrumented for datagram arrival, so
what the client emitted before exiting was never seen.

Record what was actually observed — the argument was refused, the client
exited non-zero, and it printed none of the connection line it prints in
every other scenario — and state plainly that the emission was not
observed. The conclusion that matters is unchanged: continuous mode was
not exercisable through this CLI.

No implementation, test or protocol evidence changes.
2026-08-13 17:39:28 +07:00
Nils Andreas Svee 750e28200a docs(server): scope the strict-mode restriction claim
The environment notes said restriction scenarios were run both with and
without --loose, but the matrix records a strict run for the
packet-length restriction only; the interval and maximum-duration rows
were --loose alone. Say that, so the artifact does not credit
strict-mode coverage it never recorded.

No implementation, test or protocol evidence changes.
2026-08-13 17:35:21 +07:00
Nils Andreas Svee 02bdb495a1 docs(server): qualify interop validation claims
Two claims in the validation record were stronger than the evidence
behind them.

The maximum-duration Close conclusion was stated for every conforming
client. That does not follow: the deadline is evaluated on arrival, not
on transmission, so an echo sent legitimately before the negotiated
Duration elapsed can still be queued long enough to arrive after the
server's two-second grace and carry Close. Scope the result to the
loopback run that was actually performed, and describe the Close as a
termination path for traffic continuing or arriving beyond the grace,
including delayed in-flight traffic.

The document also claimed the validation was reproducible from the
commands above, while carrying only summarized client arguments and no
harness — the harness was disposable on purpose and is not being
promoted into repository infrastructure. Say plainly that this is a
record of the validation performed rather than a reproduction harness.

No implementation, test or protocol evidence changes.
2026-08-13 17:15:37 +07:00
Nils Andreas Svee a50edff624 docs(server): record upstream client interop
Run the released upstream IRTT 0.9.1 client as a black box against the
Tokio server over explicit loopback listeners, and record the result.

Twelve of thirteen matrix rows pass and one is skipped for a client-side
CLI reason: IPv4 and IPv6 ordinary sessions, HMAC with a wrong-key
negative control, packet-length and interval restrictions both strictly
and with --loose, maximum-duration restriction, no-test, and midpoint
timestamps on each clock selection. No incompatibility was found, so
nothing in the implementation changed.

Two results are worth keeping. The single-field midpoint reply this
server deliberately emits instead of upstream 0.9.1's dual-field form is
accepted by the released client, which derives RTT and IPDV from it. And
the maximum-duration close is unreachable by a conforming client: the
deadline is the configured maximum plus two seconds while negotiation
caps the session Duration at that same maximum, so a client honoring the
Duration it was given always stops early. The two-second grace is the
measured margin already recorded in the protocol evidence, so this is
the implementation matching that evidence rather than a defect, and the
close path stays covered by the lifecycle tests.

Upstream source, tests and history were not inspected; only the
executable's version, help, exit status and normal output were observed.
No test in this repository depends on the upstream binary.
2026-08-13 16:47:09 +07:00
Nils Andreas Svee ace3defe22 Merge pull request #81 from Lochnair/feat/server-tokio-runtime
feat(server): add Tokio UDP runtime
2026-08-13 11:35:06 +07:00
Nils Andreas Svee 8d240e352d docs(server): update runtime status 2026-08-13 02:06:41 +07:00
Nils Andreas Svee 41e39bd5b9 fix(server): keep shutdown responsive during sends 2026-08-13 01:50:25 +07:00
Nils Andreas Svee 85c432fcf8 test(server): exercise async client interop 2026-08-13 01:35:34 +07:00
Nils Andreas Svee 4fb5ca6d15 feat(server): add tokio udp runtime 2026-08-13 01:32:05 +07:00
Nils Andreas Svee d45f607b9c Merge pull request #80 from Lochnair/feat/server-lifecycle-policy
feat(server): enforce rate and session lifetime
2026-08-13 01:06:55 +07:00
Nils Andreas Svee c7537b9c42 fix(server): take the idle interval cap before saturating 2026-08-13 00:55:23 +07:00
Nils Andreas Svee c2994f64a4 feat(server): enforce rate and session lifetime 2026-08-13 00:45:40 +07:00
Nils Andreas Svee 683b52d334 feat(server): add lifetime negotiation policy 2026-08-13 00:24:42 +07:00
Nils Andreas Svee 391748f907 Merge pull request #79 from Lochnair/feat/server-echo-core
feat(server): implement echo core
2026-08-12 23:52:44 +07:00
Nils Andreas Svee cce2ff731a fix(server): order an echo reply's timestamp pair 2026-08-12 23:47:34 +07:00
Nils Andreas Svee 8abab7ee38 feat(server): implement echo core 2026-08-12 23:21:02 +07:00
Nils Andreas Svee 5901c52323 Merge pull request #78 from Lochnair/feat/server-packet-length-policy
feat(server): bound negotiated packet lengths
2026-08-12 22:41:03 +07:00
Nils Andreas Svee 0e7d363f96 feat(server): bound negotiated packet lengths 2026-08-12 22:25:14 +07:00
Nils Andreas Svee 964e7d6339 Merge pull request #77 from Lochnair/fix/server-open-executable-params
fix(server): require an executable open before acknowledging it
2026-08-12 20:58:26 +07:00
Nils Andreas Svee 3845142d30 fix(proto): reject packet lengths usize cannot represent 2026-08-12 20:05:01 +07:00
Nils Andreas Svee c833b0663d docs(proto): correct the echo length saturation note 2026-08-12 19:40:17 +07:00
Nils Andreas Svee ca2c3dcbd8 fix(server): reject unusable timestamp sessions 2026-08-12 17:21:23 +07:00
Nils Andreas Svee 68ed5c6e2e fix(proto): floor negative echo lengths 2026-08-12 17:16:03 +07:00
Nils Andreas Svee 2e2db4d3e0 Merge pull request #76 from Lochnair/docs/server-deep-dive-airlock
docs(protocol): airlock server black-box findings
2026-08-12 16:40:57 +07:00
Nils Andreas Svee 58472dbf7b docs(protocol): airlock server black-box findings
Transfer independently black-box-verified server behavior from a source-assisted
research phase conducted outside this repository. Only a sanitizer-candidate
report of black-box findings and the packet captures it referenced were admitted;
no upstream source, tests, history, harnesses, build trees, result files or
source-assisted analysis crossed the boundary. Provenance and the boundary
discipline are recorded in docs/clean-room/CLEANROOM_NOTES.md.

New upstream robustness defect (Section 22.5). An open selecting timestamps while
omitting the Clock parameter is accepted with a valid token and no Clock echoed,
and the first echo on that session is fatal to the server process, taking
unrelated sessions on the same process with it. An explicit Clock=0 is instead
rejected at open, so parameter presence is externally meaningful. Recorded as an
observed defect with a must-not-crash recommendation; the clean server's
resolution is deliberately left to the implementation work.

Also established or strengthened:

- the idle grace and maximum-duration grace are fixed additive margins across the
  settings swept, and the maximum-duration clock starts at the first echo that is
  actually served;
- a rate-limited request is the only tested drop class that keeps a session
  alive, and in one documented configuration a conforming client can observe the
  difference;
- a post-deadline packet of several tested classes consumes an expired session's
  one remaining reply, including packets that are themselves dropped;
- the reply-length cap is the bind interface's MTU on the interfaces where it was
  reachable, and a length the host cannot transmit ends the process;
- DSCP marking follows the session, with unmarked open replies and no
  cross-session leakage across interleaved sessions;
- repeated tags are last-valid-wins, with invalid-duplicate handling scoped to
  the orders actually tested, and unknown tag values are consumed as varints.

Three captures were admitted, each rewritten to the container convention already
used here and verified byte-identical in packet data and timestamps:
server-clock-absent (3 packets), server-expiry-consumption (14) and
server-dscp-interleaved (32). Two further captures were reviewed and rejected as
redundant or unsupporting; their textual results were kept instead.

Host-specific figures are labelled as such throughout, upstream defects are not
promoted to protocol requirements, observations are separated from inferences and
from recommendations, and no implementation policy is chosen here. The client
specification gains matching refinements where the two compatibility baselines
would otherwise state a lifecycle or marking rule differently.

Documentation and evidence only; no production code changed.
2026-08-12 16:24:57 +07:00
Nils Andreas Svee 035fb5a385 Merge pull request #75 from Lochnair/feat/server-client-close
feat(server): handle client close
2026-08-12 10:55:07 +07:00
Nils Andreas Svee 6c34fd3d20 docs(server): record IPv6 zone identity as project policy
Endpoint identity documented the IPv6 scope identifier as an established
component alongside family, address and port. The server specification does
list the zone, but its remaining-unknowns section records that multi-zone
behavior could not be tested on a single-host platform, so no evidence settles
how a reference server compares scoped addresses. Presenting the choice as
verified compatibility overstated what is known, which the crate's own guidance
forbids.

Say what it is instead: a deliberate policy taken under a recorded unknown. Two
peers reachable at the same link-local address in different zones are genuinely
different peers, so treating the zone as identity is the conservative reading —
the failure it prevents is one peer closing another's session, and the failure
it risks is a scoped client having to reopen. The excluded flow label is
labelled the same way, and `irtt-server` guidance now carries the rule so the
echo slice reuses this comparison rather than inventing a second one.

Behavior is unchanged; this is documentation and a test comment.
2026-08-12 10:48:42 +07:00
Nils Andreas Svee e79ea94318 fix(server): exclude IPv6 flow information from endpoint identity
Session identity is the address family, the IP address, the UDP source port
and — for IPv6 — the scope identifier. `SocketAddrV6` equality additionally
covers the flow label, so comparing endpoints with `==` admitted a field that
identifies no endpoint: a client whose flow label changed mid-session could be
refused its own close and strand the session, which nothing reclaims while
expiry is unimplemented. It bought nothing in exchange, since the token,
address, port and scope are what an off-path attacker would have to guess.

Compare the specified components instead. Scope still counts, and the address
is still stored verbatim, so a later reply goes back to exactly the endpoint
the request came from.

Not reachable through a default socket today: a receiver reports the flow
label only when asked to, and there is no runtime slice yet. It is reachable
through the public core, which takes caller-supplied addresses.
2026-08-12 10:42:11 +07:00
Nils Andreas Svee d269f77c87 feat(server): handle client close
Release the session a client close names. A close is bound to a session only
when the token matches and the datagram arrived from that session's exact
endpoint, so holding a token is not by itself authority to tear the session
down: a close from a foreign source port, address, family or IPv6 scope leaves
the session live, and one close cannot take down a sibling session the same
peer opened separately. A token that is unknown, already closed or zero matches
nothing and leaves the table untouched; zero is reserved for no-test replies and
never issued.

Nothing is sent either way. Protocol version 1 defines no acknowledgement for a
client close, so a successful close returns the same `Ok(None)` as a rejected
one and a delivered close is indistinguishable to the client from a lost one.
A standalone `Reply|Close` datagram is tolerated by clients but has no
demonstrated use, so none is encoded.

Authentication still runs before the session is looked up. That keeps a bad MAC
on a live token indistinguishable from an unknown token, and means a close that
fails authentication — missing, wrongly keyed, corrupted, or unexpected on a
server with no key — cannot remove a session. Trailing bytes after the token
stay acceptable: the structural decoder already tolerates them and the server
does not re-parse the request.

The session table remains the only record of how full the server is, so a close
frees capacity immediately and a refused open can succeed once one is released.
Close draws no token and touches no other state; the endpoint lookup borrows
the table only until the comparison is made, so the removal needs no clone.

Echo remains ignored. The test that pinned close as a no-op is replaced by
`tests/close.rs`, which covers removal, silence, repeated close, echo after
close, endpoint binding, unknown/zero/stale tokens, authentication failure,
trailing bytes, sibling sessions and capacity reuse; `unsupported.rs` keeps
echo and structurally invalid traffic.
2026-08-12 10:30:50 +07:00
Nils Andreas Svee be66a6a61d Merge pull request #74 from Lochnair/feat/server-core-open
feat(server): add open session core
2026-08-12 01:12:41 +07:00
Nils Andreas Svee b7e206f939 refactor(server): keep session state crate-private
`Session` and the token lookup that returns it were public, which published
internal session state before there is a consumer for it. The next slices are
all but certain to add and restructure fields here — receive counts and
windows, sequence tracking, activity and expiry state — and nothing outside
the crate needs a session yet.

Make `Session` and `ServerCore::session` crate-private. `session_count`
stays public: how much session state the server holds against its bound is a
sensible stable observable, unlike the sessions themselves.

The endpoint accessors have no production reader until echo and close
processing arrive, since a session's endpoint has to be captured at open time
and is not recoverable later. They are marked accordingly rather than left to
warn.
2026-08-12 01:10:56 +07:00
Nils Andreas Svee 59f6e88005 feat(server): add open session core
Add `irtt-server` with the deterministic protocol and session engine the
upcoming Tokio runtime will call. `ServerCore::handle_datagram` takes a source
endpoint and the bytes of one datagram and returns the bytes to send back, or
nothing. It performs no I/O and keeps no clock, so every decision is a pure
function of its state and that input apart from drawing session tokens.

Rejection is silence. The protocol defines no error reply, no reset and no
NACK, so malformed, unauthenticated and policy-refused datagrams are discarded
without a response and without disturbing any live session; a client tells
"rejected" from "lost" only by timing out. `ServerError` is reserved for
internal failures on the server's own side, so an inbound `ProtoError` never
surfaces as a server failure.

Open handling authenticates before decoding parameters, so a packet that fails
authentication is discarded without parsing untrusted data and no reply
behavior reveals which stage rejected it. Duration and Interval are accepted
when absent and rejected when explicitly present and not positive, which is
what the presence-aware parameter decode is for. Negotiation currently rewrites
only the protocol version; the restriction knobs the specification describes
are deliberately left for their own slices, with one obvious place to add them.

Sessions are bounded to a finite default. A single unauthenticated datagram
creates one and opens are never deduplicated, so an unbounded table is a
liability rather than a compatibility feature; upstream's absence of session
bounds is explicitly not a target. A full table drops new opens silently and
evicts nothing. No-test opens keep working at capacity because they create no
state.

Session tokens come from the operating system's random source, since a token is
the only thing standing between an off-path attacker and a usable session on an
unauthenticated server. A drawn value that is zero or collides with a live
session is redrawn within a finite budget; there is no predictable fallback,
and a colliding token never overwrites the session that holds it. The reply is
encoded before the session is inserted, so an internal failure cannot leave a
half-created session behind.

Echo and close requests pass admission and are then deliberately ignored: this
slice creates sessions, and lifecycle and receive semantics belong to the
slices that implement them. Tests pin that so a half-implemented lifecycle
cannot appear by accident.
2026-08-12 00:47:46 +07:00
Nils Andreas Svee 93491e7762 refactor(proto): preserve open parameter presence
`Params::decode` seeded `Params::default()` and overwrote only the tags it
saw, so an omitted tag and one explicitly encoded as zero were
indistinguishable afterwards. The protocol gives those the same value but not
the same meaning, and a server cannot apply its open admission rules without
telling them apart: an absent Duration or Interval is accepted as the wire
default zero, while one explicitly encoded as zero is invalid.

Add `Params::decode_with_presence`, returning the decoded values alongside a
`ParamPresence` recording which known tags appeared. `Params::decode` now
delegates to it, so there is still exactly one parser and the two cannot
diverge. Unknown tags stay ignored and untracked, and repeated known tags keep
last-value-wins.

Add `Clock::Unspecified = 0` as the default, which is the wire default for an
absent Clock tag and what an empty parameter payload decodes to. An explicit
Clock tag encoding zero remains an invalid enum value, so `Unspecified` is
only ever reached by omission; `encode` omits the tag for it rather than
emitting an explicit zero. It selects no clock, so `has_wall`/`has_mono` are
false and no timestamp field is laid out.

`ClientConfig::clock` is public and therefore now constructible as
`Unspecified`, which is a wire default rather than a client mode. Reject it
during config validation. The client default stays `Clock::Both`, so normal
client-generated params and wire vectors are unchanged.
2026-08-12 00:23:14 +07:00
Nils Andreas Svee 78e503ffcc Merge pull request #73 from Lochnair/refactor/proto-request-codecs
refactor(proto): unify request codecs
2026-08-11 23:22:41 +07:00
Nils Andreas Svee 5a4272e7fd refactor(proto): unify request codecs
Replace the three per-kind request encoders and decoders with one
directional pair, ahead of introducing irtt-server.

Sender side: encode_request(RequestToEncode, hmac_key) constructs OPEN,
CLOSE and ECHO requests from borrowed values. Params and the ECHO payload
are borrowed rather than owned, removing a Params clone and a payload Vec
from the client's hot paths. Wire output is byte-identical; all captured
vectors still pass unchanged.

Receiver side: decode_request(packet) structurally classifies an inbound
request with no HMAC key, no negotiated Params, no session state and no
allocation. A server must extract a token before it knows which key
applies, so authentication is a separate step. Classification follows the
verified server spec: reserved bits and FLAG_REPLY are rejected, OPEN
takes precedence over CLOSE and over an echo-shaped body, and only the
structural minimums (4/12/16, plus 16 with FLAG_HMAC) are enforced.
Trailing bytes are tolerated, no negotiated length ceiling is applied, and
a zero token is structurally valid. OPEN parameters stay encoded bytes so
an authenticating receiver can verify before decoding untrusted data, and
everything after an ECHO sequence number is exposed as an opaque tail.

Add verify_packet_hmac(key, packet), which locates the standard field
itself and rejects a packet lacking FLAG_HMAC. hmac_present reports packet
layout, never authentication. The offset-based verify_hmac stays public as
the low-level primitive for hand-built and captured wire vectors, matching
the existing compute_hmac pair.

Refactor the private envelope into structural header decoding, codec flag
rules, HMAC presence policy and verification as separate steps. The reply
codecs compose the same steps in the same order, so decode_open_reply and
decode_echo_reply keep their exact error ordering and the client's
fatal-versus-ignore classification is unchanged. Reply decoding stays
state-specific: the client already knows which reply to expect, and the
two need different semantic context.

Migrate the production client and every fake peer to the new API; the
fake peers now admit requests the way a server does, instead of parsing
packets by hand.
2026-08-11 23:22:20 +07:00
Nils Andreas Svee c91e7917f7 Merge remote-tracking branch 'origin/renovate/lock-file-maintenance' 2026-08-11 23:22:03 +07:00
Nils Andreas Svee e380924414 Merge remote-tracking branch 'origin/renovate/tokio-1.x' 2026-08-11 23:22:03 +07:00
Nils Andreas Svee 7ea71f0811 Merge remote-tracking branch 'origin/renovate/nix-0.x' 2026-08-11 23:22:03 +07:00
Nils Andreas Svee db0bc282f3 Merge pull request #72 from Lochnair/audit/recv-window-downstream-loss
docs(stats): clarify server receive-window semantics
2026-08-11 23:22:03 +07:00
Nils Andreas Svee 0977db1fe2 docs(stats): clarify server receive-window semantics
An audit of the server-reported receive window found no defect in the
statistics pipeline: the bitmap is decoded raw, stored raw, never
interpreted, and never feeds the directional loss estimates, which use
the cumulative server receive count.

The public rustdoc was silent on all of that, leaving consumers of
PacketCounts free to read a window of 0x1 as a 63-packet loss burst.
Document the field's contract, name the count as the only server input
to LossStats, and cover the window-only case with a regression test.
2026-08-11 23:21:58 +07:00
renovate[bot] e0332b4360 chore(deps): maintain cargo lockfile 2026-08-11 14:25:00 +00:00
renovate[bot] 86c6422f14 fix(deps): update rust crate tokio to 1.53.1 2026-08-11 14:24:43 +00:00
renovate[bot] 554d197f14 fix(deps): update rust crate nix to 0.31.3 2026-08-11 14:24:34 +00:00
Nils Andreas Svee d5c3e21a05 Merge pull request #68 from Lochnair/fix/dscp-wire-semantics
fix(client): align DSCP wire semantics
2026-08-11 21:09:26 +07:00
Nils Andreas Svee 3d5a8fae14 fix(client): align DSCP wire semantics 2026-08-11 20:29:57 +07:00
Nils Andreas Svee 347af39d74 Merge pull request #67 from Lochnair/docs/server-protocol-airlock
docs(protocol): add sanitized server compatibility research
2026-08-11 18:34:51 +07:00
Nils Andreas Svee e0e8d3870b docs(agents): add server protocol guidance 2026-08-11 18:34:22 +07:00
Nils Andreas Svee 454bbc2378 docs(protocol): add sanitized server compatibility research 2026-08-11 18:25:31 +07:00
Nils Andreas Svee 8a94bc7ca6 Merge pull request #66 from Lochnair/fix/upstream-midpoint-echo-compat
fix(client): accept upstream midpoint echo replies
2026-08-11 17:43:05 +07:00
Nils Andreas Svee 88dd4f2a62 fix(proto): accept upstream midpoint echo replies
Upstream irtt 0.9.1 always emits both midpoint timestamp fields (wall,
then monotonic) in an ECHO reply for single-clock StampAt::Midpoint
negotiations, even though only one clock was negotiated, so its header is
one timestamp longer than the negotiated header.

The resulting datagram length is not simply negotiated_len + 8. Both
forms are max(negotiated_length, header), so the extra field lengthens
the datagram only while the larger header still exceeds the negotiated
length, and displaces payload beyond that; the difference is 8, then
7..1, then 0.

decode_echo_reply now accepts the compatibility length wherever it is
strictly longer than the negotiated length, parsing both wire fields and
exposing only the negotiated clock's value. Where the two lengths
coincide the forms are indistinguishable from the packet alone, so the
reply is accepted with normal negotiated parsing rather than a heuristic
- a monotonic-only midpoint negotiation therefore cannot always be
corrected, which is documented and tested. Every other length stays
strictly rejected.

Echo request decoding and encode_echo_reply are unchanged: the encoder
must keep emitting the sane negotiated layout.
2026-08-11 16:26:29 +07:00
Nils Andreas Svee 3d197c2353 ci: expand test matrix to include musl and aarch64 targets
Consolidate check and test jobs into a matrix strategy to verify compatibility across x86_64 and aarch64 architectures for both glibc and musl toolchains.
2026-08-11 14:23:06 +07:00
Nils Andreas Svee 8472745b42 Merge pull request #65 from Lochnair/fix/ancillary-safe
refactor(client): replace unsafe libc calls with nix for Linux ancillary data
2026-08-11 13:58:22 +07:00
Nils Andreas Svee 373bf32d0f refactor(client): replace unsafe libc calls with nix for Linux ancillary data
Eliminate unsafe code blocks and manual pointer arithmetic in Linux-specific receive paths by utilizing nix for socket options and control message parsing. This improves safety and maintainability while preserving the existing kernel RX timestamp and traffic class functionality.
2026-08-11 13:38:56 +07:00
Nils Andreas Svee 3c6c0e396f Merge pull request #64 from Lochnair/fix/tui-bound-input-drain
fix(tui): bound input event draining
2026-08-11 00:25:03 +07:00
Nils Andreas Svee 8651e93351 fix(tui): bound input event draining
handle_input() previously drained terminal events in an unbounded
while-poll loop, so a continuously-ready input stream could keep the
frontend inside handle_input() indefinitely, starving managed-event
processing, completion checks, peer-close stop policy, and rendering
in the outer run_tui() loop.

Bound each call to at most INPUT_EVENT_WORK_BUDGET (128) polled
terminal events, mirroring the MANAGED_EVENT_WORK_BUDGET pattern
already used for managed event draining. All polled events count
against the budget, including ignored/unknown/release events, so a
stream of non-actionable input can't bypass the bound. Remaining
events are left queued for the next outer-loop iteration rather than
dropped. Quit/Ctrl-C now also breaks out of the drain immediately
after being handled, since there is no benefit to consuming further
buffered input before the outer loop can request managed shutdown.
2026-08-11 00:12:37 +07:00
Nils Andreas Svee 17bbf72c6c Merge pull request #63 from Lochnair/fix/tui-lazy-graph-history
fix(tui): allocate graph history lazily
2026-08-10 23:59:11 +07:00
Nils Andreas Svee 17aeaf0b9b fix(tui): allocate graph history lazily 2026-08-10 23:57:27 +07:00
Nils Andreas Svee 3df71e0b69 fix(cli): account for targets in stats memory warning 2026-08-10 23:35:16 +07:00
Nils Andreas Svee b555451448 test(cli): make integration synchronization deterministic (#61) 2026-08-10 23:21:00 +07:00
Nils Andreas Svee 8924cc531c test(client): use protocol encoders in fake peers 2026-08-10 22:53:36 +07:00
Nils Andreas Svee e5c08997ed test(cli): use protocol encoders in fake peers 2026-08-10 21:55:54 +07:00
Nils Andreas Svee f0f9277469 test: trim redundant coverage 2026-08-10 21:06:49 +07:00
Nils Andreas Svee 186c6eba97 docs: add clean-room docs/specs and agent guidance 2026-08-10 18:54:22 +07:00
renovate[bot] 49826a5b3d fix(deps): update rust crate thiserror to 2.0.20 2026-08-10 11:15:42 +07:00
renovate[bot] d4cef6c2e7 fix(deps): update rust crate clap to 4.6.6 2026-08-10 11:15:26 +07:00
Nils Andreas Svee 8c31f14419 Merge pull request #55 from Lochnair/fix/managed-timeout-budget
fix(managed): bound timeout work per poll
2026-08-10 11:14:52 +07:00
Nils Andreas Svee 301542186a test(cli): bound peer-close synchronization waits 2026-08-10 11:09:00 +07:00
Nils Andreas Svee 04ea4af079 test(cli): synchronize multi-target peer close 2026-08-10 11:00:12 +07:00
Nils Andreas Svee 1f69c04274 test(managed): verify sparse timeout backlog progress 2026-08-10 10:30:36 +07:00
Nils Andreas Svee edb634146c fix(managed): retain timeout backlog fairness 2026-08-10 10:25:10 +07:00
Nils Andreas Svee ab9912b3d5 test(managed): cover timeout backlog scheduling 2026-08-10 02:06:41 +07:00
Nils Andreas Svee ec7baebe3a fix(managed): bound timeout discovery 2026-08-10 01:59:13 +07:00
Nils Andreas Svee f65998ff70 refactor(client): make deadline indexing allocation-free 2026-08-10 01:56:49 +07:00
Nils Andreas Svee 4632e310d7 test(managed): verify timeout budget fairness 2026-08-10 01:18:38 +07:00
Nils Andreas Svee a67331a297 fix(managed): bound timeout work per poll 2026-08-10 01:05:41 +07:00
Nils Andreas Svee 9769a1b49e refactor(client): index pending probe deadlines 2026-08-10 01:00:50 +07:00
Nils Andreas Svee fe3a4dc8e5 Merge pull request #54 from Lochnair/renovate/github-actions
chore(deps): update github actions
2026-08-10 00:45:41 +07:00
renovate[bot] 0bb75b04ef chore(deps): update github actions 2026-08-09 17:39:20 +00:00
Nils Andreas Svee 24d36d41d8 Merge pull request #53 from Lochnair/fix/managed-liveness-control
fix(managed): harden liveness and control bounds
2026-08-10 00:38:45 +07:00
Nils Andreas Svee 8d47dc9c6e test(managed): cover stopping admission overlap 2026-08-10 00:27:18 +07:00
Nils Andreas Svee dd396a8b5a test(managed): verify bounded control progress 2026-08-10 00:06:31 +07:00
Nils Andreas Svee dd1182d24d fix(managed): harden control admission 2026-08-10 00:05:48 +07:00
Nils Andreas Svee 1c6b4be915 fix(client): bound asynchronous opening work 2026-08-09 23:45:19 +07:00
Nils Andreas Svee c8d7c7eda2 Merge pull request #52 from Lochnair/refactor/managed-frontend-cutover
refactor(managed): remove legacy runtime
2026-08-09 22:05:17 +07:00
Nils Andreas Svee 33fe01f252 refactor(irtt-client): gate functions that are only used with tokio enabled 2026-08-09 22:03:45 +07:00
Nils Andreas Svee 36eaa90e2a fix(cli): harden managed frontend cutover 2026-08-09 21:51:20 +07:00
Nils Andreas Svee c599c84715 refactor(managed): remove legacy runtime 2026-08-09 20:58:36 +07:00
Nils Andreas Svee 1a9e8f28aa refactor(cli): use unified managed frontend 2026-08-09 20:27:22 +07:00
Nils Andreas Svee 9de95b303c feat(managed): support subscribed blocking start 2026-08-09 20:21:12 +07:00
Nils Andreas Svee f3ea8e030a Merge pull request #51 from Lochnair/blocking-managed-owner
feat(managed): add blocking managed owner
2026-08-09 20:07:50 +07:00
Nils Andreas Svee 93fed6dce6 fix(managed): bound blocking runtime shutdown 2026-08-09 20:02:35 +07:00
Nils Andreas Svee 749306394c test(managed): verify blocking owner lifecycle 2026-08-09 19:18:38 +07:00
Nils Andreas Svee 03ee6e2f28 feat(managed): add blocking managed owner 2026-08-09 19:10:56 +07:00
Nils Andreas Svee 53d29f4cf9 Merge pull request #50 from Lochnair/feat/tokio-managed-target-updates
feat(managed): add dynamic target generations
2026-08-09 19:03:54 +07:00
Nils Andreas Svee de902722e7 test(managed): verify dynamic target generations 2026-08-09 18:52:16 +07:00
Nils Andreas Svee 89ec57d48c feat(managed): add transactional target updates 2026-08-09 18:51:46 +07:00
Nils Andreas Svee 87b41bb379 Merge pull request #49 from Lochnair/feat/tokio-managed-task
feat(managed): add unified Tokio managed task
2026-08-05 00:19:17 +07:00
Nils Andreas Svee 99b6ddfead fix(managed): preserve drain and pacing invariants 2026-08-05 00:09:46 +07:00
Nils Andreas Svee 8445c0cd99 fix(managed): harden stop and deadline ordering 2026-08-04 23:19:18 +07:00
Nils Andreas Svee 1af8afcfae fix(managed): correct pacing and drain cleanup 2026-08-04 22:33:00 +07:00
Nils Andreas Svee 1cb6e07706 test(managed): verify unified task lifecycle 2026-08-04 20:59:42 +07:00
Nils Andreas Svee fd3c29e63a feat(managed): add unified Tokio managed task 2026-08-04 20:38:52 +07:00
Nils Andreas Svee 49b34bf85c Merge pull request #48 from Lochnair/refactor/async-client-poll-core
refactor(client): share pollable async transactions
2026-08-04 20:10:04 +07:00
Nils Andreas Svee a24898ada6 refactor(client): share pollable async transactions 2026-08-04 19:17:33 +07:00
Nils Andreas Svee 0f02bf54ec Merge pull request #47 from Lochnair/feat/tokio-async-client
feat(client): add Tokio async client
2026-08-04 16:46:00 +07:00
Nils Andreas Svee b67dcfb425 test(client): focus async adapter coverage 2026-08-04 16:28:27 +07:00
Nils Andreas Svee 2f7932862a fix(client): reformat tokio dependency entry 2026-08-04 15:54:41 +07:00
Nils Andreas Svee b1e32d543c test(client): verify async lifecycle equivalence 2026-08-04 15:34:20 +07:00
Nils Andreas Svee 62a9c8899e feat(client): add Tokio async client 2026-08-04 15:34:19 +07:00
Nils Andreas Svee a542d4edf1 feat(client): add optional Tokio socket support 2026-08-04 15:29:35 +07:00
Nils Andreas Svee 069da5cee9 Merge pull request #46 from Lochnair/fix/managed-opening-drain
fix(managed): bound opening receiver drains
2026-08-04 14:39:23 +07:00
Nils Andreas Svee 56f54a7b4e fix(managed): classify opening overflow as configuration 2026-08-04 14:24:40 +07:00
Nils Andreas Svee 04e7893e3e fix(managed): bound opening receiver drains 2026-08-04 14:22:23 +07:00
Nils Andreas Svee a2d9b5af81 Merge pull request #45 from Lochnair/fix/transactional-close
fix(client): make session closing transactional
2026-08-04 13:54:46 +07:00
Nils Andreas Svee 3948e3dec9 fix(client): timestamp close events after send 2026-08-04 13:44:33 +07:00
Nils Andreas Svee ef19c45c1f fix(managed): transact group session close 2026-08-04 13:08:38 +07:00
Nils Andreas Svee 357e1356ea fix(client): transact blocking session close 2026-08-04 13:03:51 +07:00
Nils Andreas Svee da731edd0d refactor(session): prepare close transactions 2026-08-04 12:59:27 +07:00
Nils Andreas Svee d3a18b0331 Merge pull request #43 from Lochnair/refactor/transactional-opening
refactor(client): make session opening transactional
2026-08-04 12:45:58 +07:00
Nils Andreas Svee 185e0bf3e4 fix(managed): drain queued opening replies before retry 2026-08-04 12:34:13 +07:00
Nils Andreas Svee dd74f3800e fix(client): preserve no-test opening semantics 2026-08-04 12:18:31 +07:00
Nils Andreas Svee e270183f5d refactor(managed): transact group session opening 2026-08-04 12:12:05 +07:00
Nils Andreas Svee ff27ec24c6 refactor(client): transact blocking session opening 2026-08-04 12:11:32 +07:00
Nils Andreas Svee dad4d2a298 refactor(session): prepare opening transactions 2026-08-04 12:11:32 +07:00
Nils Andreas Svee 983aec5bab Merge pull request #44 from Lochnair/fix/probe-send-timestamps
fix(client): separate probe admission and send timing
2026-08-04 11:48:08 +07:00
Nils Andreas Svee e642f962d4 fix(client): separate probe admission and send timing 2026-08-04 11:36:25 +07:00
Nils Andreas Svee 8de478777d Merge pull request #42 from Lochnair/refactor/transactional-probes
refactor(client): make probe sends transactional
2026-08-04 01:13:39 +07:00
Nils Andreas Svee 9bc2ac90b3 refactor(client): commit probe sends transactionally 2026-08-04 01:04:42 +07:00
Nils Andreas Svee b5d0b1e52e refactor(probe): preflight pending insertion 2026-08-04 00:53:26 +07:00
Nils Andreas Svee 1a189854e7 Merge pull request #41 from Lochnair/feat/directional-codecs
feat(proto): add directional packet codecs
2026-08-04 00:48:25 +07:00
Nils Andreas Svee cabe0f6c24 feat(proto): add directional echo codecs 2026-08-04 00:34:12 +07:00
Nils Andreas Svee cc7f7fc9b2 feat(proto): add directional open and close codecs 2026-08-04 00:30:08 +07:00
Nils Andreas Svee e908c4ce4f refactor(proto): centralize packet envelope handling 2026-08-04 00:27:45 +07:00
Nils Andreas Svee 6ba76abca0 Merge pull request #40 from Lochnair/refactor/probe-scheduling
refactor(client): move probe scheduling out of SessionMachine
2026-08-04 00:16:21 +07:00
Nils Andreas Svee 1920d5e33c test(managed): make probe pacing assertions deterministic 2026-08-04 00:11:27 +07:00
Nils Andreas Svee 22b8bb80ea refactor(client): separate probe scheduling from session state 2026-08-04 00:11:27 +07:00
renovate[bot] 561e405d18 chore(deps): maintain cargo lockfile 2026-08-03 17:10:21 +00:00
renovate[bot] 1cb4d01746 fix(deps): update rust crate clap to 4.6.5 2026-08-04 00:06:02 +07:00
renovate[bot] 01787362f5 chore(deps): update github actions 2026-08-04 00:05:19 +07:00
Nils Andreas Svee 549efa8330 Merge pull request #38 from Lochnair/refactor/session-machine-state
Make session lifecycle state explicit
2026-08-03 23:41:59 +07:00
Nils Andreas Svee 4b4adc518c refactor(session): consolidate client session lifecycle 2026-07-31 11:10:23 +07:00
Nils Andreas Svee dc827908d9 refactor(session): separate negotiation from session state 2026-07-31 11:08:25 +07:00
Nils Andreas Svee dfe0534905 test(managed): cover group late replies during final drain
Exercise a real one-target ManagedClientGroup through timeout, Draining, a gated retained reply, LateReply classification, local close, and authoritative TargetFinished publication. Synchronize release after EchoLoss and a scheduler-handled no-op update so the ordering is deterministic rather than delay-dependent.

Add a silent-server control that verifies completion only after the final-drain deadline, and assert bounded join, queued-event draining through disconnection, target counters, terminal outcome equality, and group aggregates in both cases.

Harden the analogous single-client late-drain test with a viable one-probe run window and packet-shape checks. No production final-drain behavior change was required.
2026-07-28 15:55:18 +07:00
Nils Andreas Svee 7cc55bd619 fix(managed): decouple peer-close control from event delivery
Add a scheduler-owned saturating peer-close counter shared with the group session. Update it before best-effort TargetFinished publication so bounded subscriber overflow cannot hide the live shutdown condition, while retaining the existing events, bounded history, and aggregate accounting.

Make grouped CLI and TUI supervision consult the durable count on each existing activity loop and funnel both count and event observations through one-shot stop policy. Preserve interruption suppression, queued-event draining, structured failures, drop reporting, and peer-close diagnostic precedence.

Cover scheduler-only accounting, non-peer exclusions, receiver and removal races, deterministic capacity-one DropOldest eviction with an active target, frontend one-shot policy, and the existing process-level peer-close and interruption behavior.
2026-07-28 15:49:53 +07:00
Nils Andreas Svee bbe3261eb3 fix(managed): record peer closure in the receiver
After publishing the authenticated reply classification and session-close events, mark the target PeerClosed while the receiver still holds its lock. Wake the scheduler to publish and account for the single authoritative terminal outcome.

Add controlled-order regressions proving later scheduler sends and target removal cannot overwrite peer-close provenance, counters remain intact, and ordinary replies stay active.
2026-07-28 14:55:30 +07:00
Nils Andreas Svee dfe9adb36e fix(managed): block while dynamic groups are idle
Block the group scheduler on its control channel when no opening, pacing, or final-drain deadline exists. Preserve bounded timed waits whenever scheduled work remains.

Cover no-deadline blocking, channel disconnection, deadline wakeups, and empty dynamic-group update, stop, and drop behavior with bounded tests.
2026-07-28 14:48:57 +07:00
Nils Andreas Svee 6af4663b6e fix(cli): drive single-target runs with ManagedClient
Replace the manual single-target ClientSession loops in the stream and TUI adapters with ManagedClient::start_with_subscription so the existing worker owns opening, deadline-aware pacing, receive handling, timeout polling, draining, and close.

Use bounded subscription waits for interruption and input polling, drain queued events before and after join, and report per-subscription drops without changing output, statistics, labels, summaries, warning, or peer-close policy. Add process and unit regressions for 10 ms cadence, interruption, final-event draining, TUI terminal state, and overflow visibility.
2026-07-28 14:42:02 +07:00
Nils Andreas Svee 080d45ca67 fix(cli): stop continuous groups on peer closure
Record a continuous peer-closed TargetFinished event as the reason for requesting group shutdown and stop each CLI/TUI group at most once. Drain the managed subscription and join before applying exit policy so queued output and target failures are preserved.

Give peer closure precedence over the cancellation caused by that internal stop while keeping explicit interruption and finite-run behavior distinct. Add bounded process regressions for partial closure, mixed failures, queued rows, and interruption plus focused TUI policy coverage.
2026-07-28 14:29:15 +07:00
Nils Andreas Svee 83fb98dbfd test(managed): cover silent opening timeouts
Exercise the ordinary OpenTimeout path with a bound UDP peer that receives every configured opening attempt but never replies.

Bound event disconnection and join completion with one strict deadline, then verify the authoritative terminal event, structured failure, aggregate counters, and retained outcome.
2026-07-28 10:23:56 +07:00
Nils Andreas Svee 58d11f1dec fix(cli): fail continuous runs on peer closure
Keep PeerClosed as a distinct successful library outcome while treating it as an error at the CLI and TUI boundary for uninterrupted continuous runs.

Track aggregate peer-close outcomes independently of bounded recent history and cover finite, continuous, grouped, mixed-failure, interruption, and TUI status behavior.
2026-07-28 10:23:47 +07:00
Nils Andreas Svee 8ea4c2f8a4 docs(managed): document lifecycle outcome contracts
Document terminal group events, structured failure decisions, explicit static versus dynamic completion, empty desired-set behavior, bounded join history, dropped-event detection, and missed-slot pacing.
2026-07-28 01:04:44 +07:00
Nils Andreas Svee 6adec06442 fix(managed): expose dropped subscription events
Track a monotonic discarded-event count per bounded subscription for all overflow policies, including after hub sealing or disconnection. Preserve nonblocking publication and existing queue policies.

Warn once in streaming CLI output and mark TUI summaries/status as incomplete when DropOldest discarded events. Add exact overflow-counter and incomplete-marker regressions.
2026-07-28 01:04:43 +07:00
Nils Andreas Svee 41cd2a9589 fix(managed): skip missed probe slots
Advance delayed managed schedules to the latest due cadence slot and then directly to the first future deadline. Apply the same absolute-cadence behavior to the single runner and group burst/staggered pacing, while leaving the manual Client send API caller-paced.

Bound receive waits by the next send deadline so 10 ms intervals are not hidden behind the managed socket timeout. Add deterministic delayed-slot, finite-end, burst, staggered, and receive-bound regressions.
2026-07-28 01:04:43 +07:00
Nils Andreas Svee 5381cfc992 fix(managed): bound completed target retention
Retain only the 256 most recent target outcomes in the final group snapshot while tracking total, successful, failed, and discarded outcome counts. Publish each authoritative TargetFinished event before inserting into the bounded history.

Update CLI/TUI exit decisions to use aggregate counts and add a dynamic churn regression proving every outcome remains observable while join retention stays bounded.
2026-07-28 01:04:42 +07:00
Nils Andreas Svee 8e58c0f04b fix(managed): keep empty dynamic groups idle
Track desired membership separately from active target state and make natural completion an explicit group policy. Empty authoritative updates now remove all targets without terminating the scheduler, while static CLI groups retain all-target completion.

Document that construction still requires an address-family-defining target and add regressions for empty replacement, later re-addition, idle control responsiveness, idle cancellation, and finite static completion.
2026-07-28 01:04:42 +07:00
Nils Andreas Svee 4398d25d4b fix(cli): report multi-target failures and return nonzero
Consume structured per-target outcomes in the streaming CLI and TUI so mixed failures remain visible while successful targets still produce output. Return a failure status when every target fails, and keep interruption behavior distinct.

Add strict-timeout integration coverage for mixed continuous completion and all-open-failure exit status, plus focused TUI outcome-state tests.
2026-07-28 01:04:42 +07:00
Nils Andreas Svee 97aac829ec fix(managed): publish terminal outcomes for failed targets
Extend the managed group stream with authoritative per-target terminal outcomes and structured failure classifications. Final client events are published first, followed by exactly one TargetFinished outcome for success, peer close, no-test, opening/runtime failure, removal, or cancellation.

Scheduler-owned cleanup now stops the receiver and seals subscriptions on natural completion, eliminating the event-consumer/join circular wait while preserving queued drain. TargetEventSubscription now yields ManagedGroupEvent, so consumers must handle Client and TargetFinished variants.

Strict-timeout regressions cover mixed success/failure, all-open failure, runtime failure, no-test ordering, removal, cancellation, and disconnection before join.
2026-07-28 00:07:18 +07:00
Nils Andreas Svee 3e0047e505 fix(managed): seal completed event hubs
Store hub closure under the same lock as subscriber registration so permanent shutdown cannot race with a late subscription. Sealing remains idempotent, wakes current consumers, preserves queued events for draining, rejects future subscribers, and ignores later publications.

Regression tests cover queued drain, blocking wakeup, idempotent sealing, and late subscription rejection.
2026-07-27 23:59:51 +07:00
Nils Andreas Svee 1bf44a1746 fix(client): honor authenticated close before correlation
Treat a decoded, authenticated, token-matched close flag as a session-level signal after classifying the packet. Classification events now deterministically precede SessionClosed for pending, duplicate, retained-timeout, evicted-late, and untracked sequences.

Wrong-token and bad-HMAC close packets remain non-terminal. Public event documentation records the ordering contract, and focused regressions cover every correlation class.
2026-07-27 23:58:16 +07:00
Nils Andreas Svee 91db85cc63 fix(client): preserve peer-close counters and provenance
Snapshot sent counters when a runtime leaves its live session state, and expose PeerClosed from the single managed runner instead of folding it into TestComplete. Group timeout polling now recognizes a peer-closed runtime before attempting another operation, preserving the same provenance and counters.

Regression coverage verifies single and group peer-close outcomes, finite completion, cancellation, no-test completion, and final counters.
2026-07-27 23:55:04 +07:00
Nils Andreas Svee 40167ab51a chore: update msrv to 1.88 to reflect ratatui's msrv 2026-07-27 20:51:10 +07:00
renovate[bot] 7f797644ad chore(deps): maintain cargo lockfile
(cherry picked from commit c4e3e1e629)
2026-07-27 20:44:40 +07:00
renovate[bot] 6828adba0c chore(deps): update github actions to v0.6.1
(cherry picked from commit 36517a7d07dfed5016101c0cd34e0f8e379fd7d6)
2026-07-27 20:44:32 +07:00
Nils Andreas Svee a311b1e0e4 chore: update README to be more clear 2026-07-27 20:28:04 +07:00
renovate[bot] 2ee97f281a chore(deps): maintain cargo lockfile 2026-07-23 12:33:47 +07:00
renovate[bot] b6c88794e6 fix(deps): update socket dependencies 2026-07-23 12:33:13 +07:00
renovate[bot] bbb8a6a458 fix(deps): update rust crate thiserror to 2.0.19 2026-07-23 12:30:30 +07:00
renovate[bot] c895faa5d1 fix(deps): update rust crate clap to 4.6.4 2026-07-23 12:27:29 +07:00
renovate[bot] 5b4bab79ca chore(deps): update github actions 2026-07-23 12:27:16 +07:00
Nils Andreas Svee 15abd7c3ad chore(deps): ignore release workflow in renovate updates
Exclude .github/workflows/release.yml from Renovate to prevent automated dependency updates from interfering with sensitive release processes.
2026-07-23 11:38:17 +07:00
renovate[bot] c779bf08b8 chore(deps): maintain cargo lockfile 2026-07-05 22:53:23 +00:00
Nils Andreas Svee 8f39ebe8aa ci: downgrade runner to ubuntu-22.04 and enable full features
Revert the GitHub Actions runner to ubuntu-22.04 for better compatibility and ensure the "full" feature set is passed to cargo-dist during the build process.
2026-07-05 00:33:11 +07:00
Nils Andreas Svee 68c7f92cb4 chore: bump version to 0.4.0 across all workspace packages
Update the version of irtt-proto, irtt-client, irtt-stats, and irtt-cli to 0.4.0 in preparation for a new release.
2026-07-05 00:30:19 +07:00
Nils Andreas Svee d2ae68a05f feat(tui): refactor graph metrics and simplify view navigation
Consolidate graph modes into a single GraphMetric enum to support more granular metrics like one-way delays and server processing. Replace the "full graph" toggle with a dedicated TuiView system to improve UI state management and status line clarity.
2026-07-05 00:28:22 +07:00
Nils Andreas Svee f9c6728855 feat(tui): add time-based graph navigation and zooming
Implement a scrollable and zoomable viewport for the TUI graphs, allowing users to pan through historical data using arrow keys and adjust the time window with zoom controls. This transitions graph rendering from sample-count based to timestamp-based for more accurate temporal visualization.
2026-07-04 23:44:18 +07:00
Nils Andreas Svee b9f5367ed1 feat(tui): add manual Y-axis scaling and improve graph axis logic
Introduce a toggleable graph scale (Auto, 100ms–2s) via the 's' key to provide better control over visualization ranges and implement smarter Y-axis bounds that handle signed versus non-negative metrics.
2026-07-04 22:54:47 +07:00
Nils Andreas Svee c483a12173 chore(deps): update time crate to 0.3.53
Update the time dependency in Cargo.lock to the latest patch version to maintain the lockfile and incorporate upstream fixes.
2026-07-03 00:58:05 +07:00
Nils Andreas Svee c5ca3d5a93 test(cli): add integration tests for disabled client feature
Verify that the CLI correctly reports the unavailability of the client applet and provides instructions on how to enable it via features.
2026-07-03 00:57:37 +07:00
Nils Andreas Svee baa2a862f5 feat(tui): expand multi-target graph metrics and enhance visualization
Enable switching between different RTT metrics in multi-target mode and add color-coded labels to the target table for better correlation with graph series.
2026-07-03 00:53:57 +07:00
renovate[bot] e987d31f2f chore(deps): update github actions 2026-07-03 00:50:28 +07:00
renovate[bot] 6602b7496a chore(deps): maintain cargo lockfile 2026-06-28 17:30:52 +00:00
Nils Andreas Svee a62c53a77e feat(tui): improve multi-target lifecycle management and UI clarity
Refine the TUI event loop to explicitly handle different exit conditions and ensure sessions stop correctly on idle timeouts or interruptions. Update UI labels to clarify when metrics or headers refer specifically to the first target in multi-target sessions.
2026-06-23 23:41:35 +07:00
Nils Andreas Svee 836a8abbeb feat(tui): add multi-target support and improved dashboard visualization
Enable simultaneous probing of multiple targets within the TUI using a new target status table and multi-series RTT graph. Refactor target resolution logic into a shared module to support consistent behavior across CLI and TUI components.
2026-06-23 22:36:49 +07:00
Nils Andreas Svee 2065fe5e11 test(cli): add comprehensive tests for multi-target resolution and output
Add unit tests for target normalization and address family filtering, and expand integration tests to verify column rendering and CSV output for multiple targets. Update subscriber config to drop oldest events under backpressure for better CLI stability.
2026-06-23 21:38:23 +07:00
Nils Andreas Svee 69fd24ce64 feat(cli): add multi-target probing support and labels
Enable concurrent probing of multiple targets using positional arguments or the `--target` flag with unique labels. Update output formats to include a `target` column for logical attribution in multi-target runs.
2026-06-23 21:26:46 +07:00
Nils Andreas Svee a8622ee15a build: allow Zlib license in cargo-deny configuration
Add Zlib to the list of accepted licenses to support new dependencies or updates that utilize this license.
2026-06-23 21:20:39 +07:00
Nils Andreas Svee 5dce8d55ee build: update time crate from 0.3.50 to 0.3.51
Update the time dependency in Cargo.lock to the latest patch version.
2026-06-23 21:14:28 +07:00
Nils Andreas Svee 543f0e57c8 fix(client): prevent premature task rescheduling in staggered pacing
Ensure targets are only scheduled when they are actually due to prevent unchanged targets from resending too early when the active set changes.
2026-06-23 21:12:01 +07:00
Nils Andreas Svee acffc3cd4e feat(client): implement multi-target ManagedClientGroup for shared-socket probing
Introduce a managed group runner to support concurrent probing of multiple targets over a single UDP socket with coordinated send pacing. Refactor the existing client logic into a reusable SessionRuntime to support both individual and grouped session management.
2026-06-23 21:02:54 +07:00
Nils Andreas Svee ac82537043 docs: clarify applet availability and server implementation status
Update README to specify that irtt-tui requires the tui feature and explicitly state that server support is not yet implemented.
2026-06-22 17:03:56 +07:00
Nils Andreas Svee 170f229e7b refactor: update build documentation and switch comparison script to CSV
Update README with comprehensive build/verification instructions and modify the client comparison script to consume CSV output instead of the legacy machine format.
2026-06-22 16:01:25 +07:00
Nils Andreas Svee 5b40593fda refactor(tui): normalize late reply processing and display warning counts
Treat late replies as diagnostic events without RTT metadata to ensure consistent TUI statistics and add a visibility toggle for warning events in the packet panel.
2026-06-22 15:22:41 +07:00
Nils Andreas Svee b33f01cfe1 feat(cli): enhance help documentation and dynamic applet dispatch 2026-06-22 15:13:36 +07:00
Nils Andreas Svee f1d442d798 refactor(cli): separate binary entry points from library logic
Move the main execution logic to a dedicated internal module and create distinct entry files for each binary to improve project structure and build configuration.
2026-06-22 13:41:28 +07:00
Nils Andreas Svee d05d6a941b chore(deps): maintain cargo lockfile 2026-06-22 13:31:14 +07:00
Nils Andreas Svee 9c63a03454 feat(cli): refine default table output and introduce "default" column alias
Simplify the default table view by moving verbose columns to the verbose mode and allow users to explicitly request the default layout using `--columns default`.
2026-06-22 13:25:16 +07:00
Nils Andreas Svee b02bc53cd8 feat(cli): introduce flexible event row output formats and column selection
Replace fixed output modes with customizable table, CSV, TSV, and JSON Lines formats. This allows users to select specific data columns and control header visibility for better integration with scripts and monitoring tools.
2026-06-22 13:16:33 +07:00
renovate[bot] 4b408d3ab4 chore(deps): update github actions to v0.5.7 2026-06-22 12:20:10 +07:00
renovate[bot] f9f7d35793 chore(deps): maintain cargo lockfile 2026-06-22 12:20:07 +07:00
renovate[bot] 62e0f0cf9f chore(deps): update github actions 2026-06-21 18:24:05 +00:00
Nils Andreas Svee 193487ae64 refactor(cli): align machine output wall clock field and refine visibility
Update the machine-readable wall clock field to "event_wall_ns" for consistency and restrict visibility of internal rendering types. The extensive test suite was removed as part of this cleanup.
2026-06-21 00:19:38 +07:00
Nils Andreas Svee cfbac69ac6 build: update lockfile to version 4 and deduplicate dependencies
Upgrade Cargo.lock to version 4 and remove redundant versions of crossterm, rustix, and windows-sys to reduce binary size and build complexity.
2026-06-21 00:12:23 +07:00
Nils Andreas Svee f72f8fda5a refactor(cli): harden client output formatter 2026-06-21 00:10:56 +07:00
Nils Andreas Svee c7d03c0358 test(client): strengthen assertions for session cleanup after peer closure
Update tests to ensure no packets at all are sent following a peer-initiated closure, rather than just checking for the absence of the close flag.
2026-06-20 23:08:00 +07:00
Nils Andreas Svee 97dc1b3f8d test(client): add tests for peer-initiated session closure 2026-06-20 22:59:48 +07:00
Nils Andreas Svee e33ae1d0fc fix(client): handle peer-initiated session closure gracefully 2026-06-20 22:59:48 +07:00
Nils Andreas Svee d4976fb811 refactor(client): track session closure source 2026-06-20 22:59:48 +07:00
Nils Andreas Svee 74ceef6a70 test(cli): add unit and integration tests for applet dispatching 2026-06-20 22:59:48 +07:00
Nils Andreas Svee fe776e6672 feat(cli): add multi-applet dispatcher with subcommand support 2026-06-20 22:59:48 +07:00
Nils Andreas Svee efaa60cbd4 refactor(cli): simplify applet detection and remove feature-based defaults 2026-06-20 22:59:48 +07:00
Nils Andreas Svee dc65daec75 build: bump minimum supported rust version to 1.85
Update the rust-version in Cargo.toml and the installation requirements in README.md to reflect the new MSRV.
2026-06-20 22:35:07 +07:00
Nils Andreas Svee e3b74441a7 fix(deps): update rust crate ratatui to 0.30.2 2026-06-20 22:32:43 +07:00
renovate[bot] 5595d6e78e fix(deps): update rust crate crossterm to 0.29.0 2026-06-20 22:31:44 +07:00
renovate[bot] fe1f1a9c5b chore(deps): pin dependencies 2026-06-14 17:53:37 +00:00
Nils Andreas Svee afe07f0b9a refactor(cli): reorganize feature flags and update distribution config 2026-06-10 15:19:34 +07:00
285 changed files with 46978 additions and 11959 deletions

No files matched your search

@@ -7,7 +7,6 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
merge_group:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -24,15 +23,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: rustfmt components: rustfmt
cache-key: fmt cache: false
- name: Check formatting - name: Check formatting
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1 uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
@@ -44,15 +43,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: clippy components: clippy
cache-key: clippy cache: false
- name: Run clippy - name: Run clippy
run: | run: |
@@ -72,58 +71,65 @@ jobs:
--locked \ --locked \
-- -D warnings -- -D warnings
check: verify:
name: cargo check name: cargo ${{ matrix.task }} (${{ matrix.target }})
runs-on: ubuntu-latest strategy:
timeout-minutes: 20 fail-fast: false
matrix:
include:
- task: check
target: x86_64-unknown-linux-gnu
musl: false
steps: - task: test
- name: Checkout target: x86_64-unknown-linux-gnu
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 musl: false
with:
persist-credentials: false
- name: Install Rust toolchain - task: check
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 target: x86_64-unknown-linux-musl
with: musl: true
cache-key: check
- name: Run check without default features - task: test
run: | target: x86_64-unknown-linux-musl
cargo check \ musl: true
--workspace \
--no-default-features \
--locked
test:
name: cargo test
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install musl toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 if: matrix.musl
with:
cache-key: test
- name: Run tests
run: | run: |
cargo test \ sudo apt-get update
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache: false
- name: Run ${{ matrix.task }} with all features
run: |
cargo ${{ matrix.task }} \
--workspace \ --workspace \
--all-targets \ --all-targets \
--all-features \ --all-features \
--target ${{ matrix.target }} \
--locked --locked
- name: Run tests without default features - name: Run ${{ matrix.task }} without default features
run: | run: |
cargo test \ cargo ${{ matrix.task }} \
--workspace \ --workspace \
--all-targets \
--no-default-features \ --no-default-features \
--target ${{ matrix.target }} \
--locked --locked
docs: docs:
@@ -136,14 +142,14 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache-key: docs cache: false
- name: Check docs - name: Check docs
run: | run: |
@@ -152,3 +158,6 @@ jobs:
--all-features \ --all-features \
--no-deps \ --no-deps \
--locked --locked
- name: Run doctests
run: cargo test --workspace --doc --all-features --locked
+99
View File
@@ -0,0 +1,99 @@
name: Publish irtt-server image
on:
push:
tags:
# This repo tags each crate independently (irtt-proto/vX, irtt-client/vX,
# ...); only the app package's tag corresponds to this image.
- "irtt-rs/v*"
workflow_dispatch:
env:
IMAGE_NAME: irtt-server
IMAGE_OWNER: lochnair
GITEA_REGISTRY: git.lochnair.net
GHCR_REGISTRY: ghcr.io
# Pinned so a floating "latest" release doesn't silently change the build.
CRANE_VERSION: v0.20.2
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Determine image tags
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
version="${GITHUB_REF_NAME#irtt-rs/v}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tags=${version},latest" >> "$GITHUB_OUTPUT"
else
# Manual dispatch off a branch: tag by commit only, never touch
# "latest", so an ad-hoc build can't clobber the last release.
version="sha-$(git rev-parse --short HEAD)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tags=${version}" >> "$GITHUB_OUTPUT"
fi
# Cross-compilation is handled inside the Dockerfile via tonistiigi/xx
# (native builds, no emulation), but buildx still needs a driver that
# can assemble a multi-platform manifest list.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.GITEA_REGISTRY }}
# The automatic per-job token comes back unauthorized for package
# writes on this instance, so use a PAT (scope: write:package) instead.
# (Secret names can't start with GITEA_/GITHUB_, hence REGISTRY_*.)
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build tag list
id: tags
run: |
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
gitea_tags=""
for t in "${tag_list[@]}"; do
gitea_tags="${gitea_tags}${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t},"
done
echo "gitea=${gitea_tags%,}" >> "$GITHUB_OUTPUT"
# Gitea is the primary publish target: this is the only build. Every
# other registry gets an exact digest copy of this same image (see
# below), never a separate build, so the hash matches everywhere.
- name: Build and push to Gitea
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.gitea }}
- name: Install crane
run: |
mkdir -p "${HOME}/.local/bin"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C "${HOME}/.local/bin" crane
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
- name: Duplicate image to GHCR (same digest, no rebuild)
run: |
source_ref="${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
for t in "${tag_list[@]}"; do
crane copy "${source_ref}" "${GHCR_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t}"
done
+81
View File
@@ -0,0 +1,81 @@
name: Fuzz Smoke
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
CARGO_FUZZ_VERSION: 0.13.1
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
FUZZ_TARGET: x86_64-unknown-linux-gnu
jobs:
fuzz-smoke:
name: cargo fuzz smoke
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
- name: Install nightly Rust toolchain
# Installed alongside, but deliberately not made the directory
# default: cargo-fuzz itself is built with the default stable
# toolchain below, and only `fuzz build`/`fuzz run` are run with
# the GNU-host nightly below. Building cargo-fuzz's own dependency
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
# unstable internal attributes on detecting a nightly compiler.
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
- name: Install cargo-fuzz
run: |
set -euo pipefail
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" cargo-fuzz
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Build all fuzz targets
# libFuzzer sanitizers require dynamically linked GNU binaries; the
# runner's default Rust host can be static musl.
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
working-directory: fuzz
- name: Run bounded fuzz smoke for every target
# A single job building once and running each target sequentially
# avoids recompiling the fuzz workspace per target. Ten seconds per
# target is enough to catch a broken harness or an obvious
# regression; this is not meant to be exhaustive fuzzing.
run: |
set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
echo "::group::fuzz smoke: ${target}"
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
-max_total_time=10 \
-timeout=5
echo "::endgroup::"
done
working-directory: fuzz
@@ -25,20 +25,21 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache-key: interop-netem cache: false
- name: Check host tools - name: Check host tools and network administration access
run: | run: |
ip -V ip -V
tc -V tc -V
go version go version
sudo -n true
- name: Install upstream irtt - name: Install upstream irtt
run: | run: |
@@ -46,8 +47,8 @@ jobs:
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}" go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
"$GOBIN/irtt" version "$GOBIN/irtt" version
- name: Build irtt-rs CLI - name: Build irtt-rs client
run: cargo build -p irtt-cli --locked run: cargo build -p irtt-rs --bin irtt-client --locked
- name: Run netem interop comparisons - name: Run netem interop comparisons
run: | run: |
@@ -55,21 +56,21 @@ jobs:
--all \ --all \
--skip-build \ --skip-build \
--upstream-irtt "$GOBIN/irtt" \ --upstream-irtt "$GOBIN/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli" --irtt-rs-command "$PWD/target/debug/irtt-client"
- name: Append netem interop summary - name: Append netem interop summary
if: always() if: always()
run: | run: |
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)" latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
if [ -n "$latest_summary" ]; then if [ -n "$latest_summary" ]; then
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY" cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
else else
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY" echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
fi fi
- name: Upload netem interop artifacts - name: Upload netem interop artifacts
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: https://gitea.com/actions/gitea-upload-artifact@v7
with: with:
name: interop-netem name: interop-netem
path: target/interop-netem path: target/interop-netem
+52
View File
@@ -0,0 +1,52 @@
name: Semver
on:
workflow_dispatch:
push:
tags:
- "irtt-proto/v*.*.*"
- "irtt-client/v*.*.*"
- "irtt-server/v*.*.*"
- "irtt-stats/v*.*.*"
- "irtt-rs/v*.*.*"
permissions:
contents: read
env:
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
jobs:
semver:
name: cargo-semver-checks
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
# The CLI preserves the previous wrapper's `feature-group: all-features`
# behavior without its GitHub API/cache dependency.
- name: Install cargo-semver-checks
run: |
set -euo pipefail
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" cargo-semver-checks
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Check semver compatibility
run: cargo semver-checks check-release --all-features
+109
View File
@@ -0,0 +1,109 @@
name: Supply chain
on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
- cron: "41 2 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
ACTIONLINT_VERSION: 1.7.7
CARGO_DENY_VERSION: 0.20.2
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
jobs:
cargo-deny:
name: cargo-deny ${{ matrix.checks }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Advisories can appear after code was merged. Keep this visible,
# but don't let a surprise upstream advisory nuke every unrelated PR.
- checks: advisories
continue-on-error: true
# These are policy choices in this repo. They should block PRs.
- checks: bans licenses sources
continue-on-error: false
continue-on-error: ${{ matrix.continue-on-error }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install cargo-deny
run: |
set -euo pipefail
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" --strip-components=1 \
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Run cargo-deny
run: >-
cargo deny --manifest-path Cargo.toml --all-features check
${{ matrix.checks }}
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install actionlint
run: |
set -euo pipefail
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
--output "$archive"
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" actionlint
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: actionlint handwritten workflows
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
# required absolute action URL in interop-netem.yml, so that workflow
# is checked by the Gitea runner instead.
run: |
actionlint \
.gitea/workflows/ci.yml \
.gitea/workflows/docker-publish.yml \
.gitea/workflows/fuzz.yml \
.gitea/workflows/semver.yml \
.gitea/workflows/supply-chain.yml \
.github/workflows/actions-security.yml \
.github/workflows/ci-arm.yml \
.github/workflows/osv-scanner.yml \
.github/workflows/rust-clippy.yml
- name: actionlint generated release workflow
run: >-
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
+8 -30
View File
@@ -16,34 +16,6 @@ permissions:
contents: read contents: read
jobs: jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
zizmor: zizmor:
name: zizmor name: zizmor
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -56,11 +28,17 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
# It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set.
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
with: with:
persona: regular persona: regular
inputs: |
.github/workflows
.gitea/workflows
+80
View File
@@ -0,0 +1,80 @@
name: Rust ARM CI
on:
push:
branches:
- main
pull_request:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
verify:
name: cargo ${{ matrix.task }} (${{ matrix.target }})
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
strategy:
fail-fast: false
matrix:
include:
- task: check
target: aarch64-unknown-linux-gnu
musl: false
- task: test
target: aarch64-unknown-linux-gnu
musl: false
- task: check
target: aarch64-unknown-linux-musl
musl: true
- task: test
target: aarch64-unknown-linux-musl
musl: true
runs-on: ubuntu-24.04-arm
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install musl toolchain
if: matrix.musl
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache-key: ${{ matrix.task }}-${{ matrix.target }}
- name: Run ${{ matrix.task }} with all features
run: |
cargo ${{ matrix.task }} \
--workspace \
--all-targets \
--all-features \
--target ${{ matrix.target }} \
--locked
- name: Run ${{ matrix.task }} without default features
run: |
cargo ${{ matrix.task }} \
--workspace \
--all-targets \
--no-default-features \
--target ${{ matrix.target }} \
--locked
@@ -42,7 +42,7 @@ on:
pull_request: pull_request:
push: push:
tags: tags:
- '**[0-9]+.[0-9]+.[0-9]+*' - 'irtt-rs**[0-9]+.[0-9]+.[0-9]+*'
jobs: jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do # Run 'dist plan' (or host) to determine what tasks we need to do
+8 -17
View File
@@ -3,8 +3,7 @@
# separate terms of service, privacy policy, and support # separate terms of service, privacy policy, and support
# documentation. # documentation.
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities, # Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
# in addition to a PR check which fails if new vulnerabilities are introduced.
# #
# For more examples and options, including how to ignore specific vulnerabilities, # For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/ # see https://google.github.io/osv-scanner/github-action/
@@ -20,6 +19,7 @@ on:
- cron: "45 8 * * 1" - cron: "45 8 * * 1"
push: push:
branches: ["main"] branches: ["main"]
workflow_dispatch:
permissions: permissions:
# Require writing security events to upload SARIF file to security tab # Require writing security events to upload SARIF file to security tab
@@ -29,21 +29,12 @@ permissions:
actions: read actions: read
jobs: jobs:
# Both pinned workflows default to a recursive scan of ./, including fuzz/.
# OSV v2 excludes the root Git repository unless --include-git-root is set;
# the old --skip-git flag is no longer supported.
scan-scheduled: scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} 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@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
with:
# Example of specifying custom arguments
scan-args: |-
-r
--skip-git
./
scan-pr: scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
with:
# Example of specifying custom arguments
scan-args: |-
-r
--skip-git
./
+3 -3
View File
@@ -24,12 +24,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: clippy components: clippy
cache-key: clippy-sarif cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
-27
View File
@@ -1,27 +0,0 @@
name: Semver
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
permissions:
contents: read
jobs:
semver:
name: cargo-semver-checks
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Check semver compatibility
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2
with:
feature-group: all-features
-79
View File
@@ -1,79 +0,0 @@
name: Supply chain
on:
push:
branches:
- main
pull_request:
branches:
- main
merge_group:
schedule:
- cron: "41 2 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
cargo-deny:
name: cargo-deny ${{ matrix.checks }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Advisories can appear after code was merged. Keep this visible,
# but don't let a surprise upstream advisory nuke every unrelated PR.
- checks: advisories
continue-on-error: true
# These are policy choices in this repo. They should block PRs.
- checks: bans licenses sources
continue-on-error: false
continue-on-error: ${{ matrix.continue-on-error }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
with:
command: check
arguments: --all-features
command-arguments: ${{ matrix.checks }}
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
+206
View File
@@ -0,0 +1,206 @@
# Repository guidance
Repository-wide guidance for contributors and coding agents working on `irtt-rs`.
Before editing, also check for a more specific `AGENTS.md` in the target subtree.
## Clean-room boundary
`irtt-rs` is an independent Rust implementation of an IRTT-compatible protocol stack. It began as a clean-room client reimplementation and now contains project-specific architecture and behavior beyond that original compatibility baseline.
- Do not inspect, quote, copy, translate, or derive implementation details from upstream `heistp/irtt` source code or upstream tests.
- Do not use contaminated notes or source-derived pseudocode as implementation input.
- Use the local clean-room specification, black-box verification artifacts, current `irtt-rs` code, and current tests.
- Black-box interoperability testing against an installed upstream `irtt` binary is allowed when relevant. Treat it as an external implementation, not source material.
- If behavior remains ambiguous, state the ambiguity instead of guessing or consulting upstream implementation code.
Compatibility and interoperability references:
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` — verified client-side
compatibility baseline.
- `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md` — verified server-side
compatibility baseline.
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md` — black-box evidence and
corrections supporting both the client and server baselines.
- `docs/protocol/test-vectors/README.md` — captured client-facing packet
vectors.
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md` — black-box
server behavioral vectors, edge cases, and session behavior.
- `docs/protocol/captures/` — packet captures referenced by the verification
report and behavioral vectors. Behavioral vectors and captures are
evidence/artifacts, not architecture prescriptions.
- `docs/INTEROP_COMPARISON.md` — procedures for black-box comparison against
the upstream `irtt` executable.
- `docs/clean-room/CLEANROOM_NOTES.md` — provenance only; not a normative
protocol specification.
The verified client spec predates later `irtt-rs` architecture and project-specific features. Its scope statements, including server implementation being out of scope, describe that document's scope and are not permanent repository restrictions.
For compatibility questions, the verified spec and black-box observations define the baseline. For `irtt-rs`-specific APIs, runtime architecture, resource bounds, and later features, current code and current project documentation are authoritative.
## Architecture
Main boundary:
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-rs` (application package, `crates/irtt-app`) — presentation/orchestration.
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
- `Client`: runtime-free blocking/manual adapter.
- `AsyncClient`: optional Tokio low-level adapter.
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
- `irtt-rs` (application package, `crates/irtt-app`): argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Produces the `irtt-rs` multicall dispatcher plus the dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Do not duplicate protocol/session state machines here.
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
### Server architecture (`irtt-server`)
`irtt-server` is a first-class reusable server crate. It contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Two optional negotiation policies restrict what a session may ask the server to provide — a timestamp allowance and DSCP permission — both off by default and both settled during open negotiation rather than in the runtime. Above that one-listener primitive sits `ServerSet`, the service-level owner of one or more independent `Server`s: it binds them all or none, runs each in its own Tokio task, fans one external shutdown out to them, joins them all, and fails the group if any listener fails or stops early. Ordinary process-level polish and observability decisions are what remains of the server architecture.
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
- Unlike `irtt-client`, the server has no product requirement to remain runtime-free: `irtt-server` is intentionally **Tokio-native**, and Tokio does not need to be optional for it.
- There is currently no requirement for a blocking server API, alternate async runtimes, a runtime abstraction layer, or a runtime-free server feature. Do not build blocking/runtime variants merely for symmetry with the client, and do not invent a mirrored client-style API family (`BlockingServer`, `AsyncServer`, `ManagedServer`, `BlockingManagedServer`, or similar) ahead of agreement.
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
- The server applet exists behind the `irtt-rs` application package's `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
## Engineering rules
- Make the smallest coherent change that solves the requested problem.
- Avoid unrelated cleanup and speculative abstractions.
- Preserve public API and wire behavior unless the task explicitly changes them.
- Treat resource bounds, wake behavior, blocking, cancellation, cleanup, and lifecycle termination as correctness concerns in long-running code.
- Keep fallible preparation before irreversible side effects where transaction semantics require it.
- Do not suppress compiler or Clippy warnings merely to make checks green.
- If an apparent defect may encode an intentional design decision, inspect surrounding code/docs/history and ask rather than silently redesigning it.
## Testing policy
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage. Tests are maintained repository code: every persistent test adds review, CI, refactoring, and maintenance cost, so it must justify that cost.
### Test at the owning layer
Prefer production interfaces and component/integration tests at the lowest architectural layer that owns the complete behavior. Higher-level or process-level is not inherently better.
- `irtt-proto`: wire formats, codecs, validation, malformed input, and protocol boundaries.
- `irtt-client`: client/session behavior, multi-target orchestration, burst/staggered pacing, scheduling, lifecycle, managed events, and target updates.
- `irtt-server`: server/session behavior and server runtime/orchestration.
- `irtt-stats`: statistics, loss/IPDV accounting, rolling windows, and retention bounds.
- `irtt-rs` / `irtt-app`: behavior introduced by the application layer itself, such as argument-to-config mapping, presentation/formatting, app-specific diagnostics/exit policy, and genuine process/signal behavior.
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
Concurrency correctness is a production requirement, but deterministic coverage
does not by itself justify a test-only synchronization hook. Prefer simplifying
the concurrency machinery or observing receipts/status/events through the public
control surface before introducing or restoring internal race gates.
### Every persistent test must earn its place
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
One strong behavioral test through a normal production interface is usually better than several tests of private helpers or reconstructed internals. Be especially skeptical of unit tests that depend on private functions, methods, structs, fields, or implementation sequence: harmless internal refactors should not require test rewrites.
Avoid tests that primarily:
- assert private representation or historical implementation details;
- mirror implementation steps or exercise branches solely for coverage;
- duplicate stronger coverage at another layer;
- pin exact human-readable wording unless that wording is itself contractual;
- test standard-library/dependency behavior;
- use subprocess machinery or test-only observability when the owning crate exposes the behavior directly.
If a component/integration test through the owning crate's normal API protects the same durable behavior, prefer it unless a focused unit test materially improves precision, diagnosis, or coverage of a hard-to-observe invariant.
### Unit tests where they earn it
Focused unit/table-driven tests are appropriate for stable pure or algorithmic behavior, especially protocol codecs/vectors, statistics formulas, hand-rolled data structures, and resource/liveness invariants that cannot be observed reliably from a higher-level production interface.
### Temporary implementation tests
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition. Remove or consolidate them before finalizing unless they independently protect a durable regression or invariant.
### Resource/liveness tests and hooks
Direct low-level tests and test-only counters are justified when the invariant is otherwise not observable, e.g. bounded work per poll, no starvation/eventual progress, avoiding an otherwise O(n) hot-path operation, or exact cancellation/stop/update linearization.
Assert the invariant rather than the current mechanism used to achieve it. Prefer “bounded work and eventual progress” over a specific private cursor value when another fair implementation would also be valid.
`#[cfg(test)]` fault injection is acceptable for conditions that cannot be produced reliably through normal interfaces, such as socket-option failures, short sends, false readiness, exact cancellation points, sequence wrap, or race linearization. Do not add test-only production hooks solely for convenient observability.
### Fake peers and interoperability
Use production protocol encoders/decoders for compliant fake peers. Do not maintain shadow packet encoders in tests unless intentionally constructing malformed wire data.
Prefer a real `irtt-server` for normal compliant behavior once available; keep small adversarial peers for behavior a compliant server should not produce.
Cross-implementation tests are especially valuable because two `irtt-rs` endpoints can otherwise agree on the same mistake.
When compatibility is in question, prefer black-box interoperability testing
against an upstream `irtt` executable where practical. Follow
`docs/INTEROP_COMPARISON.md` and treat upstream only as an external executable;
do not inspect its source or tests.
### Synchronization and timing
Prefer synchronization on actual events/state over sleeps or tiny wall-clock windows.
- Use channels, condvars, packet gates, or equivalent deterministic signals with bounded failure timeouts.
- Sleeps are fine when oversleeping cannot invalidate correctness; do not sleep merely hoping another thread/process finished work.
- Avoid wall-clock assertions that include process spawn or unrelated setup time.
- Condition-wait loops must be bounded and fail with a useful diagnostic rather than hang.
When a test fails after a legitimate implementation change, determine whether production regressed or the harness encoded an invalid assumption. Do not change correct production behavior merely to satisfy a brittle test.
## Workflow and verification
For non-trivial work:
1. Inspect relevant code, tests, and local protocol/design references.
2. Identify the externally meaningful contract or internal invariant.
3. Make a short plan for multi-file or subtle lifecycle/state changes.
4. Implement the smallest coherent patch.
5. Run focused verification while iterating.
6. Review the final diff for accidental scope, brittle tests, and unnecessary test hooks.
7. Run broader verification appropriate to the risk.
Skip planning ceremony for tiny obvious edits. Do not add unrelated tests, abstractions, docs, or refactors merely because they are nearby.
Use the narrowest useful checks first. For substantive workspace changes, final verification should normally include:
```sh
cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
Also verify `--no-default-features` when changing feature gates, optional dependencies, runtime boundaries, or code intended to build without Tokio.
Interop tests require an external `irtt` binary and should only run when the task concerns interoperability or explicitly requests them. Do not run repetition loops, fuzzing, Miri, Loom, mutation testing, or similar expensive checks as routine ceremony.
Never claim a command passed unless it actually ran successfully.
## Repository hygiene
- Do not use destructive Git operations unless explicitly requested.
- Do not push, force-push, create/update a PR, or rewrite unrelated history unless explicitly requested.
- Keep commits scoped to the requested work and preserve unrelated local changes.
## Completion
Report concisely:
1. what changed;
2. verification actually run and its result;
3. meaningful tests added/updated/consolidated/removed, with justification for each persistent test added or materially expanded;
4. remaining risks, ambiguities, or follow-ups.
Do not produce a giant changelog of incidental edits.
+321
View File
@@ -0,0 +1,321 @@
# Changelog
This project's crates are versioned independently. Each release below is
scoped to a single crate and tagged as `<crate>/vX.Y.Z` (see
[Releasing](#releasing) below).
All notable changes to each crate are documented in its own section per
release. The format is loosely based on
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## irtt-proto
### 0.5.1
#### Added
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
### 0.5.0
#### Added
- New server-direction wire codecs: `decode_open_request`, `encode_open_reply`, `decode_echo_request`, `encode_echo_reply`, and `decode_close_request`, so a server implementation (now `irtt-server`) can encode/decode the same wire format the client side already used, instead of duplicating protocol logic outside this crate.
- New `ProtoError` variants `MissingField` and `UnexpectedField` for structural validation of optional fields that must be present/absent together.
#### Changed
- `encode_echo_request` (and the request-side echo codec generally) now takes the negotiated params as a separate argument rather than bundling them into `EchoRequest` — a breaking signature change for any direct caller of the proto-level echo encoder.
- `ProtoError::NegativePacketLength` was renamed to `ProtoError::PacketLengthUnrepresentable { length: i64 }`, reflecting that the check now rejects any length that doesn't fit the wire representation, not only negative ones — a breaking rename for callers matching on this variant.
#### Compatibility
- The echo-reply length codec now accepts upstream `irtt`'s longer midpoint-echo compatibility length instead of rejecting it, fixing an interop gap against real upstream servers/clients that send that length.
## 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
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
### 0.5.2
#### Fixed
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
### 0.5.1
#### Added
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
### 0.5.0
#### Added
- New optional `tokio` Cargo feature adding `AsyncClient`, a low-level non-blocking/poll-based Tokio adapter alongside the existing runtime-free blocking `Client`.
- New managed driver stack built on top of `AsyncClient`: `ManagedClientTask` / `ManagedClientHandle` (a unified Tokio-based managed driver and control surface, supporting dynamic per-target updates to a running session group) and `BlockingManagedClient` (a synchronous owner that runs a dedicated current-thread Tokio runtime internally). This replaces the project's earlier managed-client design outright rather than extending it, including changes to the `ClientError` variants it can surface.
- Linux kernel receive-timestamp capture (`SO_TIMESTAMPING`-family ancillary data) feeding one-way-delay (OWD) calculations, and equivalent kernel transmit-timestamp capture on the send side, materially improving OWD accuracy over the previous best-effort user-space send/receive timestamps on Linux. Both require the `ancillary` feature.
#### Fixed
- DSCP wire semantics: the negotiated DSCP value was being shifted twice on the wire path (double-application of the ECN/DSCP bit layout), which produced an incorrect traffic-class byte in some configurations; the client-side DSCP handling was reworked to apply the shift exactly once and is covered by expanded DSCP/negotiation tests.
- Post-send timestamp capture correctness fix so the locally-recorded send timestamp used for OWD/RTT calculations reflects when the probe actually left the socket rather than a timestamp that could be skewed by scheduling between encode and send.
- A batch of managed-session liveness and lifecycle fixes: dynamic target groups no longer spin instead of blocking while idle; empty dynamic groups stay idle correctly; peer-initiated closes are now recorded and surfaced (including counters/provenance) instead of being silently dropped or racing with correlation; authenticated close is honored ahead of reply correlation; terminal outcomes are now published for targets that fail rather than being dropped silently; completed event hubs are sealed and completed-target retention is bounded (preventing unbounded growth in long multi-target runs); missed probe slots are skipped rather than mis-scheduled; dropped-subscription events are now exposed to consumers instead of being swallowed; and a timeout-budget bug in the managed opening phase was corrected.
- Linux receive paths (blocking `Client::open`/`recv_once`/`recv_available`, `AsyncClient`'s non-blocking recv/open polling, and the MSG_ERRQUEUE TX-timestamp drain) now retry transparently on `EINTR` instead of surfacing it as a fatal socket error, while preserving each path's existing timeout/deadline contract (no retransmit, no timeout extension under repeated interruption).
- `ClientError` failure classification was made exhaustive/more precise, giving callers a fuller and more accurate error taxonomy to match on instead of a catch-all in some paths.
#### Compatibility
- The `ancillary` (Linux socket ancillary-data) feature was hardened to only perform safe operations, closing a soundness gap in how ancillary control-message buffers were handled.
## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2
#### Changed
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
### 0.5.1
#### Added
- New public `address_family_available()` helper (re-exported from `set`), for probing whether an address family can actually bind locally rather than merely constructing a socket for it. Used by the `irtt-rs` server applet to distinguish a genuinely unsupported address family from an ordinary bind failure when falling back from its default dual-family bind.
### 0.5.0
#### Added
- `irtt-server` is a new first-class, reusable crate this release, built from scratch: a deterministic `ServerCore` handling OPEN/ECHO/CLOSE packet admission, authentication policy, open negotiation, a bounded session table, echo processing with per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, server-initiated close on maximum duration, and client-initiated close.
- A reusable Tokio UDP `Server` runs one sequential `ServerCore` per listener with caller-controlled shutdown and once-per-second scheduled idle-session maintenance (in addition to exact logical expiry on authenticated, structurally valid requests). `ServerSet` sits above it as the service-level owner of one or more independent `Server`s: it binds them all-or-none, runs each in its own Tokio task, fans one external shutdown signal out to all of them, joins them, and fails the group if any listener fails or stops early — enabling multi-listener deployments from a single `ServerConfig`.
- Each reply now carries the raw traffic class it must be sent with, applied to the listener socket immediately before every send.
- Wildcard listeners recover each request's local destination from packet ancillary metadata and send that request's reply from the same address, on Linux, macOS, and FreeBSD. `Server::from_socket`/`Server::bind` are now fallible specifically because of this: a wildcard bind on a platform without that path is refused at construction (`ServerRuntimeError::WildcardSourceSelectionUnsupported`) rather than silently served from a routing-table-chosen source address. Explicit-address listeners are unaffected on all platforms.
- Configurable echo-reply payload fill policy (`ServerFill`), controlling what bytes a session's echo replies are padded with.
- Two optional negotiation policies, both off by default and settled during open negotiation: a timestamp allowance and a DSCP permission, restricting what a session may ask the server to provide.
- Linux kernel receive-timestamp capture for inbound packets, feeding more accurate server-side timing metadata, mirroring the equivalent client-side capability added in `irtt-client`.
#### Fixed
- The DSCP/traffic-class byte applied to replies was corrected to match the client-side wire-semantics fix (see `irtt-client`), so server-echoed DSCP marking is now consistent with what clients expect.
- Executable-params validation on OPEN requests was tightened so malformed/contradictory negotiated parameters are rejected during open negotiation rather than accepted and mishandled later.
- Packet-length policy corrected so oversized/invalid negotiated lengths are rejected consistently with the updated `irtt-proto` length validation.
- A receive-drop/backpressure policy fix for malformed or unauthenticated inbound packets, preventing them from disrupting the session table or other in-flight sessions.
- A receive-timestamp "wall clock" fix on the kernel RX-capture path, and a 32-bit (i686) `timespec` conversion correctness fix covered by a dedicated regression test.
- The server's ancillary receive loop now retries transparently on `EINTR` instead of terminating the listener, matching the equivalent `irtt-client` fix.
#### Compatibility
- Resource bounds are enforced by design (bounded session table, per-listener `max_sessions`, rate limiting, idle/max-duration expiry) rather than mirroring upstream `irtt`'s effectively unbounded session/per-peer behavior; this is a deliberate divergence, not an oversight, and is documented in `crates/irtt-server/AGENTS.md`.
## 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
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
### 0.5.0
#### Fixed
- Exact median calculation for `send_call`, `timer_error`, and `server_processing` event-duration statistics, which previously always reported `None` for these medians instead of a computed value.
#### Added
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
## irtt-rs
### 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
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
### 0.7.0
#### Added
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
#### Changed
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
### 0.6.2
#### Added
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
#### Changed
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
### 0.6.1
#### Added
- The `irtt-server` applet now has sensible zero-argument defaults: with no `--bind`, it binds the wildcard IRTT port on both address families (`[::]:2112` then `0.0.0.0:2112`) instead of requiring an explicit address, on platforms where wildcard reply-source selection is supported. Any explicit `--bind` fully replaces the default pair rather than adding to it.
#### Changed
- If one of the two default listeners fails because its address family has no local support on the host (e.g. IPv6 administratively disabled on an otherwise IPv4-capable host), the server now falls back to serving just the surviving family instead of failing outright. Any other failure reason (port in use, permission denied, no safe wildcard reply-source path) still fails startup as it would for an explicit `--bind`.
### 0.6.0
#### Changed
- The application Cargo package was renamed from `irtt-cli` to `irtt-rs`, moving to `crates/irtt-app`. This is the application distribution package that produces the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries; it is unrelated to the reusable `irtt-client` library crate, which keeps its existing name and version.
- The `irtt-cli` executable was hard-renamed to `irtt-client` to avoid reading as an umbrella CLI now that the application also ships a first-class server. There is no `irtt-cli` compatibility binary or alias.
- Dedicated binaries now have role-specific entry points (`irtt-client`, `irtt-tui`, `irtt-server`) instead of all funneling through the multicall dispatcher's argv0/subcommand logic. Each dedicated binary always runs its own role regardless of the name it is invoked or copied under, and no longer links code for the other applet roles it doesn't implement. The `irtt-rs` binary remains the feature-selectable multicall dispatcher and is the only binary that inspects argv0 or subcommands.
- Future application releases move to the `irtt-rs/vX.Y.Z` tag namespace (previously `irtt-cli/vX.Y.Z`); see [Releasing](#releasing).
## irtt-cli
### 0.5.0
#### Added
- A new `server` applet (the `irtt-server` binary and `irtt-rs server` subcommand), gated behind the `server` Cargo feature which is now part of `irtt-cli`'s default feature set. It is thin orchestration over the new `irtt-server` crate: one current-thread Tokio runtime, one repeatable `--bind`, and `ServerSet` for startup/shutdown/listener-failure handling — including for a single bind.
- Positional `[LABEL=]TARGET` argument syntax for specifying client targets.
#### Changed
- Target specification moved from a `--target LABEL=TARGET` flag to plain positional `[LABEL=]TARGET` arguments — a breaking CLI syntax change for any script invoking targets via the old flag.
- The `Target` column is now always present in default, CSV, TSV, and JSONL output, rather than only appearing once more than one target was specified. This is a breaking output-format change for scripts that parsed single-target output and assumed no `Target` column.
- Cargo feature flags were simplified: the separate `stats`, `full`, and `client-runtime` features were removed, and statistics support is now mandatory whenever the `client` (or `tui`) feature is enabled, rather than optional. Consumers building with `--no-default-features` and selectively re-enabling a `stats` or `client-runtime` feature will need to update their feature selection.
- The stats-related memory-usage warning now scales with the number of configured targets (using `irtt-stats`'s new `estimated_retained_bytes`), instead of assuming a single target's worth of retained memory.
#### Fixed
- Single-target runs are now driven through the same `ManagedClient` path as multi-target runs, instead of a separate single-target code path, fixing behavioral drift between the two (e.g. continuous single-target runs now stop correctly on peer-initiated close and multi-target failures are now reported with a nonzero exit status).
- Continuous (unbounded-duration) runs, both single- and multi-target, now stop and report correctly when the peer closes the session, instead of hanging or exiting silently.
- The TUI's input-event draining is now bounded per tick, fixing a starvation/liveness issue where a burst of input could stall rendering or probe scheduling.
- The TUI's live graph history buffer is now allocated lazily instead of eagerly, avoiding unnecessary upfront allocation for graphs that are never shown.
- A managed-session terminal-state reconciliation fix in the CLI's use of the managed client, correcting a case where a target's final/terminal outcome could be reconciled incorrectly.
#### Compatibility
- MSRV raised to Rust 1.88 (from 1.85), to track `ratatui`'s minimum supported Rust version; this applies to the whole workspace, including `irtt-cli`.
- CI test coverage was expanded to include `musl` and `aarch64` targets, increasing confidence in `irtt-cli` binaries built for those platforms (no code-level behavior change).
## Releasing
Starting with this release, crates are versioned and tagged independently
rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
(e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
libraries published to [crates.io](https://crates.io); their tags exist to
identify the exact source for a given crates.io release and do not produce a
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
to build and publish a GitHub Release with platform binaries. The historical
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
+4
View File
@@ -0,0 +1,4 @@
# Claude Code
Read and follow `AGENTS.md` before making changes. When working below a subtree
that contains a more specific `AGENTS.md`, read and follow that file as well.
Generated
+1616 -221
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -2,32 +2,32 @@
members = [ members = [
"crates/irtt-proto", "crates/irtt-proto",
"crates/irtt-client", "crates/irtt-client",
"crates/irtt-server",
"crates/irtt-stats", "crates/irtt-stats",
"crates/irtt-cli", "crates/irtt-app",
"crates/irtt-integration-tests",
] ]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
rust-version = "1.75" rust-version = "1.88"
repository = "https://github.com/Lochnair/irtt-rs" repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs" homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies] [workspace.dependencies]
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" } irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.3.0", path = "crates/irtt-client" } irtt-client = { version = "0.6.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" } irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true
codegen-units = 1 codegen-units = 1
panic = "abort"
strip = "symbols" strip = "symbols"
# The profile that 'dist' will build with # The profile that 'dist' will build with
[profile.dist] [profile.dist]
inherits = "release" inherits = "release"
lto = "thin"
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
# Builds the irtt-server applet only, statically linked against musl, and
# ships it in a scratch image with no shell or libc.
#
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
# picks the right musl target triple, sysroot, and linker for whatever
# platform buildx asked for.
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
COPY --from=xx / /
RUN apk add --no-cache clang lld file
ARG TARGETPLATFORM
RUN xx-apk add --no-cache musl-dev
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
RUN xx-cargo build \
--release \
--locked \
-p irtt-rs \
--bin irtt-server \
--no-default-features \
--features server \
--target-dir /build/target \
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
FROM scratch
COPY --from=builder /irtt-server /irtt-server
ENTRYPOINT ["/irtt-server"]
+143 -141
View File
@@ -1,178 +1,180 @@
# irtt-rs # irtt-rs
`irtt-rs` is a Rust implementation of an IRTT-compatible client. `irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
finite or continuous UDP latency probing, plus the server side those probes
run against.
It is not the upstream IRTT project. For the original implementation, protocol background, and broader project documentation, see: This is not the upstream IRTT project. For the original implementation,
protocol background, and broader documentation, see
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
mistaken for it.
<https://github.com/heistp/irtt> ## Features
## Status * Finite and continuous probe sessions
* Concurrent probing of multiple targets, with staggered or burst pacing
* Reusable client library with a structured event stream (blocking, Tokio,
or managed)
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
monitoring systems, with selectable output columns
* Optional terminal UI
* Reusable Tokio-native server library
* UDP server applet with sensible zero-argument defaults and explicit bind
and session-policy options
* Router-friendly multi-applet binary layout
This repository currently provides: ## Comparison with upstream IRTT
- `irtt-rs`, the canonical multi-applet binary `irtt-rs` is an independent, compatible reimplementation, not a successor or
- `irtt-cli`, a stream/text IRTT-compatible client applet replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature remains the original implementation. Interoperability is a project goal; see
- `irtt-client`, a Rust library for running client sessions and consuming events [`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
- finite and continuous probe runs [`docs/protocol/`](docs/protocol/) for the black-box interoperability
- human, simple, machine-readable, and RTT-only stream output modes methodology and protocol baseline this project verifies against. Choose
- optional local summary statistics between them based on the capabilities and deployment model you need.
Server support is not implemented. The table below was checked against upstream release
The installed binary is intentionally not named `irtt` to avoid conflicting or [v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
confusing overlap with upstream IRTT. This project's own interop testing and protocol baseline are currently
pinned to the prior release, v0.9.1; the wire protocol version number is
unchanged between the two, but v0.9.2 has not itself been exercised against
this project's interop harness.
## Install | Capability | irtt-rs | upstream IRTT (v0.9.2) |
| --- | --- | --- |
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
| Finite probing | Supported, default 10s | Supported, default 1m |
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
Requires Rust 1.75 or newer. Upstream has capabilities this project does not implement: a `report`
subcommand for replaying saved JSON results, benchmarking and
timer/clock-diagnostic subcommands, client-side payload fill and
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
server-side fill policy (this project's server always honors a session's
negotiated fill request, with a fixed fallback pattern, but has no equivalent
to upstream's flags for restricting or overriding it), syslog logging, and
bundled systemd/OpenRC service files.
From a local checkout: This project has capabilities upstream does not implement: multi-target
probing with labels, the terminal UI, CSV/TSV output with selectable
columns, a quantified and actively-warned memory-retention model for
long-running client sessions, and best-effort Linux kernel-timestamp
preference for its own send/receive endpoint.
## Installation
The build requires Rust 1.88 or newer.
Install the latest release from crates.io:
```sh
cargo install irtt-rs --locked
```
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
isolation with `--no-default-features` plus the applet's own feature, for
example a lightweight client-only install:
```sh
cargo install irtt-rs --locked --no-default-features --features client
```
For development, or to install directly from a checkout:
```sh ```sh
git clone https://github.com/Lochnair/irtt-rs.git git clone https://github.com/Lochnair/irtt-rs.git
cd irtt-rs cd irtt-rs
cargo install --path crates/irtt-cli cargo install --path crates/irtt-app --locked
``` ```
The default install provides the normal client-oriented binaries, including ## Quick start
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
Probe a server:
```sh ```sh
cargo install --path crates/irtt-cli --features tui irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
``` ```
Or run it directly without installing: Probe continuously until interrupted (`Ctrl-C`):
```sh ```sh
cargo run -p irtt-cli -- <server> irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
``` ```
For space-sensitive packaging, such as OpenWrt packages, distributors can ship Watch the same session live in the terminal UI:
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`,
`irtt-tui`, and future server applet names to it. Applet dispatch is based on
the invoked binary name.
## CLI usage
Basic test:
```sh ```sh
irtt-cli <server> irtt-tui netperf-eu.bufferbloat.net:2112
``` ```
Set duration and interval: Run a server with sensible defaults — no `--bind` needed:
```sh ```sh
irtt-cli <server> --duration 30s --interval 100ms irtt-server
``` ```
Run continuously: ## Documentation
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
formats and columns, measurement field definitions, finite/continuous
memory behavior, kernel timestamps.
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
retained history.
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
overrides, wildcard listener behavior, session policy, HMAC.
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
binary names, feature-gated builds.
* [docs.rs](https://docs.rs) for the library crates below.
* [`docs/protocol/`](docs/protocol/) and
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
protocol specification and interoperability validation against upstream
`irtt`.
Every applet also documents its full option list with `--help`.
## Crates
| Crate | crates.io | What it is |
| --- | --- | --- |
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
Each library crate has its own crates.io README describing what it's for and
when to use it instead of `irtt-client`/`irtt-server` directly.
## Binaries and features
| Build | Binaries |
| --- | --- |
| `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
## Development
```sh ```sh
irtt-cli <server> --duration 0 cargo fmt --all -- --check
cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
``` ```
Use a specific output mode: See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
clean-room provenance boundary this project maintains against upstream
```sh `irtt`.
irtt-cli <server> --output human
irtt-cli <server> --output simple
irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us
```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
interactive probing. It defaults to continuous probing, equivalent to
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
session gracefully.
```sh
irtt-tui <server>
irtt-tui <server> --duration 30s
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
```
For available options:
```sh
irtt-cli --help
irtt-tui --help
```
## Machine output
Use `--output machine` for line-oriented `key=value` output intended for scripts, monitoring, and autorate consumers.
Example:
```sh
irtt-cli <server> --duration 0 --interval 250ms --output machine
```
Each line represents one client event and starts with an `event` field, for example:
```text
event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none
```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
RTT fields are reported in microseconds. `raw_rtt_us` is the measured client
send-to-receive RTT. `effective_rtt_us` and `adjusted_rtt_us` are signed and may
be negative when server processing exceeds the raw RTT or timing correction
produces a negative adjusted value. When one-way delay fields are present,
`client_to_server_us` and `server_to_client_us` are also signed microseconds and
may be negative due to wall-clock skew.
For consumers that only need RTT values, use:
```sh
irtt-cli <server> --output rtt-us
```
## Library usage
`irtt-client` can be used directly from Rust code.
Example `Cargo.toml` dependency from a local checkout:
```toml
[dependencies]
irtt-client = { path = "crates/irtt-client" }
```
Minimal managed-client example:
```rust
use std::time::Duration;
use irtt_client::{
ClientConfig, ClientEvent, ManagedClient, SubscriberConfig,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = ClientConfig {
server_addr: "netperf-eu.bufferbloat.net:2112".to_owned(),
duration: Some(Duration::from_secs(10)),
interval: Duration::from_secs(1),
..ClientConfig::default()
};
let (session, events) =
ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
while let Ok(event) = events.recv() {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq={seq} rtt_us={}", rtt.effective.as_micros());
}
ClientEvent::SessionClosed { .. } => break,
_ => {}
}
}
let outcome = session.join()?;
println!("session ended: {:?}", outcome.end_reason);
Ok(())
}
```
+73
View File
@@ -0,0 +1,73 @@
[package]
name = "irtt-rs"
version = "0.8.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"]
[lib]
name = "irtt_app"
doc = false
[[bin]]
name = "irtt-rs"
path = "src/bin/irtt-rs.rs"
[[bin]]
name = "irtt-client"
path = "src/bin/irtt-client.rs"
required-features = ["client"]
# This applet shares its name with the irtt-client library crate, whose rustdoc
# output it would otherwise collide with.
doc = false
[[bin]]
name = "irtt-tui"
path = "src/bin/irtt-tui.rs"
required-features = ["tui"]
[[bin]]
name = "irtt-server"
path = "src/bin/irtt-server.rs"
required-features = ["server"]
# This applet shares its name with the irtt-server library crate, whose rustdoc
# output it would otherwise collide with.
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", "dep:chrono"]
[dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
clap = { version = "4.6.7", features = ["derive", "env"] }
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
futures-core = { version = "0.3.34", default-features = false, 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", "net", "time", "sync", "macros"], optional = true }
[dev-dependencies]
irtt-server.workspace = true
tokio = { version = "1.53.2", 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"
+33
View File
@@ -0,0 +1,33 @@
# irtt-rs
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
package `irtt-rs`.
Installing this crate builds:
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
server applet by subcommand.
- `irtt-client` — the dedicated client binary (feature `client`).
- `irtt-tui` — the interactive terminal UI (feature `tui`).
- `irtt-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs`
installs everything.
## This is an application, not a library
This package's library target is undocumented (`doc = false`) and exists only
to share code between its binaries. If you're looking for the client/session
or server API to embed in your own Rust program, use
[`irtt-client`](https://crates.io/crates/irtt-client) or
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
## Usage
See the [project repository](https://github.com/Lochnair/irtt-rs) for
installation instructions, command-line usage, and configuration.
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
+182
View File
@@ -0,0 +1,182 @@
use std::{ffi::OsString, path::Path};
use clap::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppletDispatch {
Run {
applet: RequestedApplet,
argv: Vec<OsString>,
},
Help(String),
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
if let Some(applet) = detect_applet_from_argv0(argv0) {
return Ok(AppletDispatch::Run { applet, argv });
}
let basename = applet_basename(argv0);
if basename.starts_with("irtt-") && basename != "irtt-rs" {
return Err(format!(
"unknown applet name '{basename}'. Known applet names: {}",
known_applet_binary_names()
));
}
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
return Err(format!(
"choose an applet: {}\n\n{}",
applet_command_names(),
dispatcher_help()
));
};
match command {
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: applet_argv("irtt-client", &argv[2..]),
}),
"tui" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: applet_argv("irtt-tui", &argv[2..]),
}),
"server" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Server,
argv: applet_argv("irtt-server", &argv[2..]),
}),
_ => Err(format!(
"unknown applet '{command}'. Choose one of: {}\n\n{}",
applet_command_names(),
dispatcher_help()
)),
}
}
pub fn dispatcher_help() -> String {
let mut command = Command::new("irtt-rs")
.about("IRTT-compatible multi-applet dispatcher")
.subcommand(Command::new("client").about(client_applet_about()))
.subcommand(Command::new("tui").about(tui_applet_about()))
.subcommand(Command::new("server").about(server_applet_about()))
.after_help(dispatcher_after_help());
command.render_help().to_string()
}
fn client_applet_about() -> &'static str {
if cfg!(feature = "client") {
"Run the stream client applet"
} else {
"Stream client applet is not available in this build"
}
}
fn tui_applet_about() -> &'static str {
if cfg!(feature = "tui") {
"Run the terminal UI applet"
} else {
"Terminal UI applet is not available in this build"
}
}
fn server_applet_about() -> &'static str {
if cfg!(feature = "server") {
"Run the UDP server applet"
} else {
"UDP server applet is not available in this build"
}
}
fn known_applet_binary_names() -> &'static str {
"irtt-rs, irtt-client, irtt-tui, irtt-server"
}
fn applet_command_names() -> &'static str {
"client, tui, or server"
}
fn dispatcher_after_help() -> String {
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
let mut missing = Vec::new();
if !cfg!(feature = "client") {
missing.push("irtt-client requires the client feature");
}
if !cfg!(feature = "server") {
missing.push("irtt-server requires the server feature");
}
if !cfg!(feature = "tui") {
missing.push("irtt-tui requires the tui feature");
}
match missing.len() {
0 => {}
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
}
help
}
fn applet_basename(argv0: &str) -> &str {
let name = Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0);
strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX)
}
/// Removes the platform executable suffix from a file name.
///
/// The suffix is part of the file name on Windows and empty everywhere else, so
/// this is a no-op on Unix. Without it, every applet name — including the
/// `irtt-rs` dispatcher itself — reaches argv0 detection as `<name>.exe` and is
/// rejected as an unknown applet. The applet names themselves are still matched
/// exactly; only this platform artifact is case-insensitive, because it is not
/// a name the user chose.
///
/// The suffix is a parameter so that Windows behavior is assertable from any
/// host, not only where `EXE_SUFFIX` happens to be non-empty.
fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str {
if suffix.is_empty() || name.len() <= suffix.len() {
return name;
}
let split = name.len() - suffix.len();
let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else {
return name;
};
if tail.eq_ignore_ascii_case(suffix) {
stem
} else {
name
}
}
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push(OsString::from(applet_name));
argv.extend(args.iter().cloned());
argv
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::client_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::dispatcher_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::server_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::tui_main()
}
+154
View File
@@ -0,0 +1,154 @@
use std::time::Duration;
use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection, TimestampArg,
};
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")]
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."
)]
pub targets: Vec<TargetArg>,
/// Read complete desired target sets from standard input in continuous mode.
#[arg(
long,
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
)]
pub targets_stdin: bool,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
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."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Event row output format.
#[arg(
long,
value_enum,
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."
)]
pub format: OutputFormat,
/// Comma-separated event row columns, or default/all.
#[arg(
short = 'c',
long,
value_name = "COLUMNS",
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
)]
pub columns: Option<String>,
/// List available event row columns and aliases, then exit.
#[arg(
long,
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
)]
pub list_columns: bool,
/// Header policy for table, CSV, and TSV output.
#[arg(
long,
value_enum,
default_value_t = HeaderMode::Auto,
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
)]
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
/// Validate the selected targets and prepare the managed run.
///
/// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target.
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned());
}
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: self.targets_stdin,
},
)
.await
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// Readable terminal table output.
Table,
/// Comma-separated event rows.
Csv,
/// Tab-separated event rows.
Tsv,
/// One JSON object per event row.
Jsonl,
}
impl OutputFormat {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HeaderMode {
/// Print headers for table, CSV, and TSV output.
Auto,
/// Always print headers where the format supports them.
Always,
/// Never print headers.
Never,
}
+7
View File
@@ -0,0 +1,7 @@
pub mod args;
pub mod output;
pub mod run;
pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use run::run_stream;
+998
View File
@@ -0,0 +1,998 @@
use std::{
fmt::Write as _,
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_client::{
ClientEvent, NegotiationResult, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
};
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
use super::args::{HeaderMode, OutputFormat};
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
#[derive(Debug, Clone)]
pub(super) struct OutputConfig {
format: OutputFormat,
columns: Vec<Column>,
header: HeaderMode,
default_table_rows: bool,
verbose: bool,
}
impl OutputConfig {
pub(super) fn new(
format: OutputFormat,
columns: Option<&str>,
header: HeaderMode,
verbose: bool,
) -> Result<Self, String> {
let uses_default_columns = columns
.map(|columns| columns.trim() == "default")
.unwrap_or(true);
let default_table_rows = format == OutputFormat::Table && uses_default_columns;
let columns = match columns {
Some(columns) => parse_columns(columns, format, verbose)?,
None => default_columns(format, verbose),
};
Ok(Self {
format,
columns,
header,
default_table_rows,
verbose,
})
}
pub(super) fn prints_summary(&self) -> bool {
self.format.prints_summary()
}
pub(super) fn summary_verbose(&self) -> bool {
self.verbose
}
pub(super) fn should_print_header(&self) -> bool {
match self.header {
HeaderMode::Always => self.format != OutputFormat::Jsonl,
HeaderMode::Never => false,
HeaderMode::Auto => matches!(
self.format,
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Tsv
),
}
}
pub(super) fn render_header(&self) -> Option<String> {
if !self.should_print_header() {
return None;
}
match self.format {
OutputFormat::Table => Some(render_table_header(&self.columns)),
OutputFormat::Csv => Some(
self.columns
.iter()
.map(|column| escape_csv(column.name()))
.collect::<Vec<_>>()
.join(","),
),
OutputFormat::Tsv => Some(
self.columns
.iter()
.map(|column| escape_tsv(column.name()))
.collect::<Vec<_>>()
.join("\t"),
),
OutputFormat::Jsonl => None,
}
}
pub(super) fn render_event(
&self,
event: &ClientEvent,
target: Option<&str>,
stats: Option<&EventStatsUpdate>,
) -> Option<String> {
let row = OutputRow::from_event(event);
if self.default_table_rows && row.is_default_table_hidden() {
return None;
}
let context = RenderContext {
stats,
target,
verbose: self.verbose,
};
match self.format {
OutputFormat::Table => Some(render_table_row(&row, &self.columns, context)),
OutputFormat::Csv => Some(render_delimited_row(
&row,
&self.columns,
context,
DelimitedFormat::Csv,
)),
OutputFormat::Tsv => Some(render_delimited_row(
&row,
&self.columns,
context,
DelimitedFormat::Tsv,
)),
OutputFormat::Jsonl => Some(render_jsonl_row(&row, &self.columns, context)),
}
}
pub(super) fn list_columns() -> String {
let mut out = String::new();
writeln!(out, "Available event columns:").unwrap();
for column in ALL_COLUMNS {
writeln!(out, " {:<24} {}", column.name(), column.description()).unwrap();
}
writeln!(out).unwrap();
writeln!(out, "Aliases:").unwrap();
for column in ALL_COLUMNS {
let aliases = column.aliases();
if !aliases.is_empty() {
writeln!(out, " {} ({})", aliases.join(", "), column.name()).unwrap();
}
}
writeln!(out).unwrap();
writeln!(out, "Special column sets:").unwrap();
writeln!(
out,
" default format default; compact for table, all columns for csv/tsv/jsonl"
)
.unwrap();
writeln!(out, " all every column").unwrap();
out
}
}
#[derive(Debug, Clone, Copy)]
struct RenderContext<'a> {
stats: Option<&'a EventStatsUpdate>,
target: Option<&'a str>,
verbose: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum OutputRow {
SessionStarted(LifecycleRow),
NoTestCompleted(LifecycleRow),
SessionClosed {
remote: SocketAddr,
token: u64,
event_wall: SystemTime,
},
EchoSent {
seq: u32,
remote: SocketAddr,
client_send_wall: SystemTime,
bytes: usize,
send_call: Duration,
timer_error: Option<Duration>,
},
EchoReply(ReplyRow),
Loss {
seq: u32,
client_send_wall: SystemTime,
},
Duplicate {
seq: u32,
remote: SocketAddr,
client_receive_wall: SystemTime,
bytes: usize,
},
Late {
reply: ReplyRow,
highest_seen: u32,
},
Warning {
kind: WarningKind,
message: String,
event_wall: SystemTime,
},
}
impl OutputRow {
fn from_event(event: &ClientEvent) -> Self {
match event {
ClientEvent::SessionStarted(irtt_client::SessionStarted {
remote,
token,
negotiation: negotiated,
at,
}) => Self::SessionStarted(LifecycleRow::new(
*remote,
Some(*token),
negotiated,
at.wall,
)),
ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted {
remote,
negotiation: negotiated,
at,
}) => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
remote: *remote,
token: *token,
event_wall: at.wall,
},
ClientEvent::EchoSent {
seq,
remote,
sent_at,
bytes,
send_call,
timer_error,
..
} => Self::EchoSent {
seq: *seq,
remote: *remote,
client_send_wall: sent_at.wall,
bytes: *bytes,
send_call: *send_call,
timer_error: *timer_error,
},
ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes,
packet_meta,
} => Self::EchoReply(ReplyRow {
seq: *seq,
remote: *remote,
client_send_wall: Some(sent_at.wall),
client_receive_wall: received_at.wall,
rtt: Some(*rtt),
server_timing: *server_timing,
one_way: *one_way,
received_stats: *received_stats,
bytes: *bytes,
packet_meta: *packet_meta,
}),
ClientEvent::EchoLoss { seq, sent_at, .. } => Self::Loss {
seq: *seq,
client_send_wall: sent_at.wall,
},
ClientEvent::DuplicateReply {
seq,
remote,
received_at,
bytes,
} => Self::Duplicate {
seq: *seq,
remote: *remote,
client_receive_wall: received_at.wall,
bytes: *bytes,
},
ClientEvent::LateReply {
seq,
highest_seen,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes,
packet_meta,
} => Self::Late {
reply: ReplyRow {
seq: *seq,
remote: *remote,
client_send_wall: sent_at.map(|sent_at| sent_at.wall),
client_receive_wall: received_at.wall,
rtt: *rtt,
server_timing: *server_timing,
one_way: *one_way,
received_stats: *received_stats,
bytes: *bytes,
packet_meta: *packet_meta,
},
highest_seen: *highest_seen,
},
ClientEvent::Warning { kind, message, at } => Self::Warning {
kind: *kind,
message: message.clone(),
event_wall: at.wall,
},
}
}
fn event_name(&self) -> &'static str {
match self {
Self::SessionStarted(_) => "session_started",
Self::NoTestCompleted(_) => "no_test_completed",
Self::SessionClosed { .. } => "session_closed",
Self::EchoSent { .. } => "echo_sent",
Self::EchoReply(_) => "echo_reply",
Self::Loss { .. } => "loss",
Self::Duplicate { .. } => "duplicate",
Self::Late { .. } => "late",
Self::Warning { .. } => "warning",
}
}
fn is_default_table_hidden(&self) -> bool {
matches!(self, Self::EchoSent { .. })
}
fn reply(&self) -> Option<&ReplyRow> {
match self {
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply),
_ => None,
}
}
fn seq(&self) -> Option<u32> {
match self {
Self::EchoSent { seq, .. } | Self::Loss { seq, .. } | Self::Duplicate { seq, .. } => {
Some(*seq)
}
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.seq),
_ => None,
}
}
fn remote(&self) -> Option<SocketAddr> {
match self {
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.remote),
Self::SessionClosed { remote, .. }
| Self::EchoSent { remote, .. }
| Self::Duplicate { remote, .. } => Some(*remote),
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.remote),
_ => None,
}
}
fn token(&self) -> Option<u64> {
match self {
Self::SessionStarted(row) | Self::NoTestCompleted(row) => row.token,
Self::SessionClosed { token, .. } => Some(*token),
_ => None,
}
}
fn bytes(&self) -> Option<usize> {
match self {
Self::EchoSent { bytes, .. } | Self::Duplicate { bytes, .. } => Some(*bytes),
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.bytes),
_ => None,
}
}
fn message(&self, context: RenderContext<'_>) -> Option<String> {
match self {
Self::SessionStarted(row) => Some(format!(
"token={:#x} duration_ns={} interval_ns={} length={}",
row.token?, row.duration_ns, row.interval_ns, row.payload_length
)),
Self::NoTestCompleted(row) => Some(format!(
"duration_ns={} interval_ns={} length={}",
row.duration_ns, row.interval_ns, row.payload_length
)),
Self::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
Self::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
Self::Warning { message, .. } => Some(message.clone()),
Self::Loss { .. } => Some("timeout".to_owned()),
Self::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
_ => None,
}
}
fn event_wall(&self) -> Option<SystemTime> {
match self {
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.event_wall),
Self::SessionClosed { event_wall, .. } | Self::Warning { event_wall, .. } => {
Some(*event_wall)
}
Self::EchoSent {
client_send_wall, ..
}
| Self::Loss {
client_send_wall, ..
} => Some(*client_send_wall),
Self::Duplicate {
client_receive_wall,
..
} => Some(*client_receive_wall),
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
}
}
fn client_send_wall(&self) -> Option<SystemTime> {
match self {
Self::EchoSent {
client_send_wall, ..
}
| Self::Loss {
client_send_wall, ..
} => Some(*client_send_wall),
Self::EchoReply(reply) | Self::Late { reply, .. } => reply.client_send_wall,
_ => None,
}
}
fn client_receive_wall(&self) -> Option<SystemTime> {
match self {
Self::Duplicate {
client_receive_wall,
..
} => Some(*client_receive_wall),
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LifecycleRow {
remote: SocketAddr,
token: Option<u64>,
event_wall: SystemTime,
duration_ns: i128,
interval_ns: i128,
payload_length: i128,
}
impl LifecycleRow {
fn new(
remote: SocketAddr,
token: Option<u64>,
negotiated: &NegotiationResult,
event_wall: SystemTime,
) -> Self {
Self {
remote,
token,
event_wall,
duration_ns: negotiated
.accepted
.duration
.map_or(0, |duration| duration.as_nanos() as i128),
interval_ns: negotiated.accepted.interval.as_nanos() as i128,
payload_length: i128::from(negotiated.accepted.length),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReplyRow {
seq: u32,
remote: SocketAddr,
client_send_wall: Option<SystemTime>,
client_receive_wall: SystemTime,
rtt: Option<RttSample>,
server_timing: Option<ServerTiming>,
one_way: Option<OneWayDelaySample>,
received_stats: Option<ReceivedStatsSample>,
bytes: usize,
packet_meta: PacketMeta,
}
// Only schema metadata is generated here; cell_for keeps rendering behavior explicit.
macro_rules! columns {
($($variant:ident($name:literal, [$($alias:literal),*], $width:expr, $align:ident, $description:literal);)*) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Column { $($variant),* }
const ALL_COLUMNS: &[Column] = &[$(Column::$variant),*];
impl Column {
fn name(self) -> &'static str {
match self { $(Self::$variant => $name),* }
}
fn parse(input: &str) -> Option<Self> {
match input {
$($name $(| $alias)* => Some(Self::$variant),)*
_ => None,
}
}
fn aliases(self) -> &'static [&'static str] {
match self { $(Self::$variant => &[$($alias),*]),* }
}
fn description(self) -> &'static str {
match self { $(Self::$variant => $description),* }
}
fn table_width(self) -> usize {
match self { $(Self::$variant => $width),* }
}
fn align_right(self) -> bool {
match self { $(Self::$variant => columns!(@align $align)),* }
}
}
};
(@align left) => { false };
(@align right) => { true };
}
// Variant(canonical name, aliases, table width, alignment, description).
columns! {
Target("target", [], 18, left, "logical CLI target label");
Event("event", [], 17, left, "event kind");
Seq("seq", [], 6, right, "probe sequence number");
Remote("remote", [], 21, left, "remote socket address");
Token("token", [], 18, left, "session token as hexadecimal");
Rtt("rtt", [], 9, right, "human-readable effective RTT");
RttUs("rtt_us", [], 10, right, "effective RTT in signed microseconds");
RawRttUs("raw_rtt_us", [], 10, right, "raw client send-to-receive RTT in microseconds");
EffectiveRttUs("effective_rtt_us", [], 16, right, "effective RTT in signed microseconds");
AdjustedRttUs("adjusted_rtt_us", [], 15, right, "adjusted RTT in signed microseconds");
ReceiveDelay("rd", ["receive_delay"], 9, right, "human-readable server-to-client delay");
ReceiveDelayUs("rd_us", ["receive_delay_us"], 10, right, "server-to-client delay in signed microseconds");
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");
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");
SendCallUs("send_call_us", [], 12, right, "send system call duration in microseconds");
TimerErrorUs("timer_error_us", [], 14, right, "scheduled-vs-actual send timer error in microseconds");
HighestSeen("highest_seen", [], 15, right, "highest sequence seen when a late reply arrived");
ServerReceivedCount("server_received", ["server_received_count"], 15, right, "server-reported received packet count");
ServerReceivedWindow("server_window", ["server_received_window"], 13, left, "server-reported received window as hexadecimal");
Dscp("dscp", [], 4, right, "received packet DSCP codepoint");
Ecn("ecn", [], 4, right, "received packet ECN bits");
TrafficClass("traffic_class", [], 13, right, "received packet traffic class byte");
KernelRxNs("kernel_rx_ns", [], 12, right, "kernel receive timestamp as Unix nanoseconds");
WarningKind("warning_kind", [], 28, left, "warning classifier");
Message("message", [], 24, left, "warning or lifecycle message");
EventWallNs("event_wall_ns", [], 13, right, "event wall timestamp as Unix nanoseconds");
ClientSendWallNs("client_send_wall_ns", [], 19, right, "client send wall timestamp as Unix nanoseconds");
ClientReceiveWallNs("client_receive_wall_ns", [], 22, right, "client receive wall timestamp as Unix nanoseconds");
DurationNs("duration_ns", [], 11, right, "negotiated test duration in nanoseconds");
IntervalNs("interval_ns", [], 11, right, "negotiated probe interval in nanoseconds");
PayloadLength("payload_length", [], 14, right, "negotiated payload length");
ServerReceiveWallNs("server_receive_wall_ns", [], 22, right, "server receive wall timestamp in nanoseconds");
ServerReceiveMonoNs("server_receive_mono_ns", [], 22, right, "server receive monotonic timestamp in nanoseconds");
ServerSendWallNs("server_send_wall_ns", [], 19, right, "server send wall timestamp in nanoseconds");
ServerSendMonoNs("server_send_mono_ns", [], 19, right, "server send monotonic timestamp in nanoseconds");
ServerMidpointWallNs("server_midpoint_wall_ns", [], 23, right, "server midpoint wall timestamp in nanoseconds");
ServerMidpointMonoNs("server_midpoint_mono_ns", [], 23, right, "server midpoint monotonic timestamp in nanoseconds");
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CellValue {
Text(String),
Integer(i128),
Unsigned(u128),
Hex(u128),
}
impl CellValue {
fn text(value: impl Into<String>) -> Self {
Self::Text(value.into())
}
fn display(&self) -> String {
match self {
Self::Text(value) => value.clone(),
Self::Integer(value) => value.to_string(),
Self::Unsigned(value) => value.to_string(),
Self::Hex(value) => format!("{value:#x}"),
}
}
fn write_json(&self, out: &mut String) {
match self {
Self::Text(value) => {
out.push('"');
write_json_string_content(out, value);
out.push('"');
}
Self::Integer(value) => write!(out, "{value}").unwrap(),
Self::Unsigned(value) => write!(out, "{value}").unwrap(),
Self::Hex(value) => {
out.push('"');
write!(out, "{value:#x}").unwrap();
out.push('"');
}
}
}
}
fn parse_columns(input: &str, format: OutputFormat, verbose: bool) -> Result<Vec<Column>, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("--columns requires at least one column".to_owned());
}
if trimmed == "all" {
return Ok(ALL_COLUMNS.to_vec());
}
if trimmed == "default" {
return Ok(default_columns(format, verbose));
}
let mut columns = Vec::new();
for raw in trimmed.split(',') {
let name = raw.trim();
if name.is_empty() {
return Err("empty column in --columns list".to_owned());
}
let column = Column::parse(name).ok_or_else(|| {
format!("unknown output column {name:?}; run --list-columns to see valid names")
})?;
columns.push(column);
}
Ok(columns)
}
fn default_columns(format: OutputFormat, verbose: bool) -> Vec<Column> {
match format {
OutputFormat::Table => {
let mut columns = vec![
Column::Target,
Column::Event,
Column::Seq,
Column::Rtt,
Column::ReceiveDelay,
Column::SendDelay,
Column::Ipdv,
Column::ServerProcessing,
Column::Message,
];
if verbose {
columns.extend([
Column::Remote,
Column::ServerReceivedCount,
Column::ServerReceivedWindow,
Column::RawRttUs,
Column::AdjustedRttUs,
Column::Bytes,
Column::Dscp,
Column::Ecn,
]);
}
columns
}
OutputFormat::Csv | OutputFormat::Tsv | OutputFormat::Jsonl => ALL_COLUMNS.to_vec(),
}
}
fn render_table_header(columns: &[Column]) -> String {
columns
.iter()
.map(|column| format_table_cell(column, Some(column.name().to_owned())))
.collect::<Vec<_>>()
.join(" ")
}
fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
columns
.iter()
.map(|column| {
let value = cell_for(row, *column, context).map(|value| value.display());
format_table_cell(column, value)
})
.collect::<Vec<_>>()
.join(" ")
}
fn format_table_cell(column: &Column, value: Option<String>) -> String {
let value = value.unwrap_or_else(|| ABSENT.to_owned());
let width = column.table_width();
let value = if matches!(column, Column::Target) {
truncate_for_table(&value, width)
} else {
value
};
if column.align_right() {
format!("{value:>width$}")
} else {
format!("{value:<width$}")
}
}
/// Shortens `value` to fit within `max_width` display characters for TABLE
/// presentation only. Values within the limit are returned unchanged. Longer
/// values keep a prefix and end with `"..."` so the result is exactly
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
/// multibyte UTF-8 value cannot be split mid-code-point.
///
/// This is a presentation-only concern: callers must keep the untruncated
/// value for CSV/TSV/JSONL and any non-display use.
fn truncate_for_table(value: &str, max_width: usize) -> String {
if value.chars().count() <= max_width {
return value.to_owned();
}
let suffix = "...";
let prefix_len = max_width.saturating_sub(suffix.len());
let mut truncated: String = value.chars().take(prefix_len).collect();
truncated.push_str(suffix);
truncated
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DelimitedFormat {
Csv,
Tsv,
}
fn render_delimited_row(
row: &OutputRow,
columns: &[Column],
context: RenderContext<'_>,
format: DelimitedFormat,
) -> String {
let separator = match format {
DelimitedFormat::Csv => ",",
DelimitedFormat::Tsv => "\t",
};
columns
.iter()
.map(|column| {
let value = cell_for(row, *column, context)
.map(|value| value.display())
.unwrap_or_default();
match format {
DelimitedFormat::Csv => escape_csv(&value),
DelimitedFormat::Tsv => escape_tsv(&value),
}
})
.collect::<Vec<_>>()
.join(separator)
}
fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
let mut out = String::from("{");
for (index, column) in columns.iter().enumerate() {
if index > 0 {
out.push(',');
}
out.push('"');
write_json_string_content(&mut out, column.name());
out.push_str("\":");
if let Some(value) = cell_for(row, *column, context) {
value.write_json(&mut out);
} else {
out.push_str("null");
}
}
out.push('}');
out
}
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
match column {
Column::Target => context.target.map(CellValue::text),
Column::Event => Some(CellValue::text(row.event_name())),
Column::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
Column::Remote => row
.remote()
.map(|remote| CellValue::text(remote.to_string())),
Column::Token => row.token().map(|token| CellValue::Hex(u128::from(token))),
Column::Rtt => row.reply().and_then(|reply| {
reply
.rtt
.map(|rtt| CellValue::text(format_signed_duration(rtt.effective)))
}),
Column::RttUs | Column::EffectiveRttUs => row.reply().and_then(|reply| {
reply
.rtt
.map(|rtt| CellValue::Integer(signed_duration_us(rtt.effective)))
}),
Column::RawRttUs => row.reply().and_then(|reply| {
reply
.rtt
.map(|rtt| CellValue::Unsigned(duration_us(rtt.raw)))
}),
Column::AdjustedRttUs => row.reply().and_then(|reply| {
reply
.rtt
.and_then(|rtt| rtt.adjusted)
.map(|adjusted| CellValue::Integer(signed_duration_us(adjusted)))
}),
Column::ReceiveDelay => row
.reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
.map(|value| CellValue::text(format_signed_duration(value))),
Column::ReceiveDelayUs => row
.reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
.map(|value| CellValue::Integer(signed_duration_us(value))),
Column::SendDelay => row
.reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
.map(|value| CellValue::text(format_signed_duration(value))),
Column::SendDelayUs => row
.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::ServerProcessing => row
.reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
.map(|value| CellValue::text(format_duration(value))),
Column::ServerProcessingUs => row
.reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
.map(|value| CellValue::Unsigned(duration_us(value))),
Column::Bytes => row.bytes().map(|bytes| CellValue::Unsigned(bytes as u128)),
Column::SendCallUs => match row {
OutputRow::EchoSent { send_call, .. } => {
Some(CellValue::Unsigned(duration_us(*send_call)))
}
_ => None,
},
Column::TimerErrorUs => match row {
OutputRow::EchoSent { timer_error, .. } => {
timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
}
_ => None,
},
Column::HighestSeen => match row {
OutputRow::Late { highest_seen, .. } => {
Some(CellValue::Unsigned(u128::from(*highest_seen)))
}
_ => None,
},
Column::ServerReceivedCount => row
.reply()
.and_then(|reply| reply.received_stats.and_then(|stats| stats.count))
.map(|value| CellValue::Unsigned(u128::from(value))),
Column::ServerReceivedWindow => row
.reply()
.and_then(|reply| reply.received_stats.and_then(|stats| stats.window))
.map(|value| CellValue::Hex(u128::from(value))),
Column::Dscp => row
.reply()
.and_then(|reply| reply.packet_meta.dscp)
.map(|value| CellValue::Unsigned(u128::from(value))),
Column::Ecn => row
.reply()
.and_then(|reply| reply.packet_meta.ecn)
.map(|value| CellValue::Unsigned(u128::from(value))),
Column::TrafficClass => row
.reply()
.and_then(|reply| reply.packet_meta.traffic_class)
.map(|value| CellValue::Unsigned(u128::from(value))),
Column::KernelRxNs => row
.reply()
.and_then(|reply| reply.packet_meta.kernel_rx_timestamp)
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::WarningKind => match row {
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
_ => None,
},
Column::Message => row.message(context).map(CellValue::text),
Column::EventWallNs => row
.event_wall()
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::ClientSendWallNs => row
.client_send_wall()
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::ClientReceiveWallNs => row
.client_receive_wall()
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::DurationNs => match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
Some(CellValue::Integer(row.duration_ns))
}
_ => None,
},
Column::IntervalNs => match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
Some(CellValue::Integer(row.interval_ns))
}
_ => None,
},
Column::PayloadLength => match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
Some(CellValue::Integer(row.payload_length))
}
_ => None,
},
Column::ServerReceiveWallNs => server_timing_i64(row, |timing| timing.receive_wall_ns),
Column::ServerReceiveMonoNs => server_timing_i64(row, |timing| timing.receive_mono_ns),
Column::ServerSendWallNs => server_timing_i64(row, |timing| timing.send_wall_ns),
Column::ServerSendMonoNs => server_timing_i64(row, |timing| timing.send_mono_ns),
Column::ServerMidpointWallNs => server_timing_i64(row, |timing| timing.midpoint_wall_ns),
Column::ServerMidpointMonoNs => server_timing_i64(row, |timing| timing.midpoint_mono_ns),
}
}
fn server_timing_i64(
row: &OutputRow,
select: impl FnOnce(ServerTiming) -> Option<i64>,
) -> Option<CellValue> {
row.reply()
.and_then(|reply| reply.server_timing)
.and_then(select)
.map(|value| CellValue::Integer(i128::from(value)))
}
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
let stats = stats?;
stats
.ipdv_pairs
.iter()
.find(|pair| pair.current_seq == seq)
.or_else(|| {
stats
.ipdv_pairs
.iter()
.find(|pair| pair.previous_seq == seq)
})
}
fn duration_us(duration: Duration) -> u128 {
duration.as_micros()
}
fn signed_duration_us(duration: SignedDuration) -> i128 {
duration.as_micros()
}
fn wall_time_ns(wall: SystemTime) -> Option<u128> {
wall.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_nanos())
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
WarningKind::WrongToken => "wrong_token",
WarningKind::UntrackedReply => "untracked_reply",
_ => "unknown",
}
}
fn escape_csv(value: &str) -> String {
if value.contains([',', '"', '\n', '\r']) {
let escaped = value.replace('"', "\"\"");
format!("\"{escaped}\"")
} else {
value.to_owned()
}
}
fn escape_tsv(value: &str) -> String {
value.replace(['\t', '\n', '\r'], " ")
}
fn write_json_string_content(out: &mut String, value: &str) {
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if c < '\u{20}' => write!(out, "\\u{:04x}", c as u32).unwrap(),
c => out.push(c),
}
}
}
+762
View File
@@ -0,0 +1,762 @@
use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
io::{self, BufRead, Write},
sync::Arc,
thread,
};
use irtt_client::{
managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{
expected_probe_count, 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,
},
worker::ManagedWorker,
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
};
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
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;
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub async fn run_stream(
args: ClientArgs,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
if args.list_columns {
print!("{}", OutputConfig::list_columns());
return Ok(());
}
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 multi_target = setup.is_multi_target();
let output_config = OutputConfig::new(
args.format,
args.columns.as_deref(),
args.header,
args.verbose,
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown).await;
}
let continuous = args.is_continuous();
let target_count = setup.target_count();
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
eprintln!("{warning}");
}
if *shutdown.borrow() {
return Ok(());
}
let (task, handle) = ManagedClient::task(setup.managed_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 {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
let mut stats = setup
.targets
.iter()
.map(|target| {
(
target.label.clone(),
StatsCollector::new(stats_config(continuous)),
)
})
.collect::<BTreeMap<_, _>>();
let mut terminal_targets = HashSet::new();
let mut dropped_events = 0_u64;
let mut interrupted = false;
let mut stop_requested = false;
loop {
let snapshot = status.borrow_and_update().clone();
if *shutdown.borrow() {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if request_managed_stop_for_peer_close(
continuous,
interrupted,
snapshot.peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
if snapshot.final_outcome.is_some() {
break;
}
tokio::select! {
_ = 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)?,
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
if interrupted {
eprintln!("interrupted, closing managed run...");
}
let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| {
process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
})?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
if outcome.discarded_target_outcomes != 0 {
eprintln!(
"irtt-rs: warning: {} final target outcomes were discarded",
outcome.discarded_target_outcomes
);
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
report_target_failure(target);
}
}
interrupted |= *shutdown.borrow();
let terminal_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
)
})
}),
};
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)?;
}
stream_output.print_summary(target_stats)?;
}
stream_output.out.flush()?;
if let Some(error) = terminal_error {
return Err(error.into());
}
Ok(())
}
#[derive(Clone)]
enum StdinUpdate {
Targets(Vec<TargetSpec>),
Stop(StdinStop),
}
fn read_stdin_record<R: BufRead>(
reader: &mut R,
buffer: &mut Vec<u8>,
) -> io::Result<Option<String>> {
buffer.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
if buffer.is_empty() {
return Ok(None);
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
reader.consume(1);
buffer.pop();
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
let before_newline = &available[..newline];
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
buffer.extend_from_slice(before_newline);
reader.consume(newline + 1);
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
let take = available.len().min(remaining);
buffer.extend_from_slice(&available[..take]);
reader.consume(take);
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
}
}
fn read_stdin_target_sets<R: BufRead>(
reader: &mut R,
updates: &tokio::sync::watch::Sender<StdinUpdate>,
) {
let mut line = 0_u64;
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
loop {
match read_stdin_record(reader, &mut record) {
Ok(Some(record)) => {
line = line.saturating_add(1);
if record.is_empty() {
continue;
}
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => {
updates.send_replace(StdinUpdate::Targets(targets));
}
Err(error) => {
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"invalid --targets-stdin line {line}: {error}"
))));
return;
}
}
}
Ok(None) => {
updates.send_replace(StdinUpdate::Stop(StdinStop::Eof));
return;
}
Err(_) => {
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"failed to read --targets-stdin line {}",
line + 1
))));
return;
}
}
}
}
fn spawn_stdin_target_reader(updates: tokio::sync::watch::Sender<StdinUpdate>) -> io::Result<()> {
thread::Builder::new()
.name("irtt-targets-stdin".to_owned())
.spawn(move || {
let stdin = io::stdin();
read_stdin_target_sets(&mut stdin.lock(), &updates);
})
.map(|_| ())
}
struct BoundedTargetSet {
limit: usize,
order: VecDeque<TargetInstance>,
members: HashSet<TargetInstance>,
}
impl BoundedTargetSet {
fn new(limit: usize) -> Self {
Self {
limit,
order: VecDeque::with_capacity(limit),
members: HashSet::with_capacity(limit),
}
}
fn insert(&mut self, target: TargetInstance) -> bool {
if !self.members.insert(target.clone()) {
return false;
}
if self.order.len() == self.limit {
let evicted = self
.order
.pop_front()
.expect("bounded target order is nonempty");
self.members.remove(&evicted);
}
self.order.push_back(target);
true
}
}
fn process_stdin_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> {
match event {
ManagedEvent::TargetStateChanged { target, .. } => {
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)?;
}
ManagedEvent::TargetFinished { outcome }
if terminal_targets.insert(outcome.target.clone()) =>
{
report_target_failure(&outcome);
}
_ => {}
}
Ok(())
}
fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
) {
let live = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<HashSet<_>>();
stats.retain(|target, _| {
live.contains(target)
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
});
}
fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
) -> BTreeSet<TargetInstance> {
let targets = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<BTreeSet<_>>();
for target in &targets {
stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
targets
}
#[derive(Clone)]
enum StdinStop {
Interrupted,
Eof,
Fatal(String),
}
async fn run_stdin_stream(
setup: ManagedRunSetup,
output_config: OutputConfig,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
if *shutdown.borrow() {
return Ok(());
}
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
let mut events = handle.subscribe()?;
let mut status = handle.subscribe_status();
let owner = ManagedWorker::start(task, handle.clone())?;
let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
spawn_stdin_target_reader(updates)?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
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();
let mut retry_after = None;
let mut dropped_events = 0_u64;
let mut stop_requested = false;
let mut stop = None;
let mut summary_targets = None;
loop {
let snapshot = status.borrow_and_update().clone();
if stop.is_none() {
if *shutdown.borrow() {
stop = Some(StdinStop::Interrupted);
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
stdin_changed = false;
match stdin.borrow_and_update().clone() {
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
.as_ref()
.is_some_and(|previous| Arc::ptr_eq(previous, &snapshot))
{
if let Some(targets) = desired.take() {
submitted = targets;
match handle.update_targets(submitted.clone()) {
Ok(receipt) => {
pending = Some(receipt);
retry_after = Some(snapshot.clone());
}
Err(_) => {
stop = Some(StdinStop::Fatal(
"failed to submit --targets-stdin update".to_owned(),
))
}
}
}
}
if stop.is_some() {
preparing = None;
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
}
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
if snapshot.final_outcome.is_some() {
break;
}
tokio::select! {
_ = 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}")));
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
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 {
Ok(_) => retry_after = None,
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() && desired_specs.is_none() && preparing.is_none() {
desired = Some(std::mem::take(&mut submitted));
} else {
retry_after = None;
}
}
Err(_) => {
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
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)?;
reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
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)
})?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
report_target_failure(target);
}
}
if outcome.discarded_target_outcomes != 0 {
eprintln!(
"irtt-rs: warning: {} final target outcomes were discarded",
outcome.discarded_target_outcomes
);
}
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
)?;
}
stream_output.print_summary(stats)?;
}
}
stream_output.out.flush()?;
match stop {
Some(StdinStop::Fatal(error)) => Err(error.into()),
_ => match outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Err(format!("managed driver failed: {failure}").into())
}
_ => Ok(()),
},
}
}
fn process_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
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)?;
}
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
report_target_failure(&outcome);
}
_ => {}
}
Ok(())
}
fn report_target_failure(target: &ManagedTargetOutcome) {
for message in target_failure_messages(target) {
eprintln!("{message}");
}
}
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
let mut messages = Vec::with_capacity(2);
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
messages.push(format!(
"irtt-rs: target {} failed ({} {}): {}",
target.target.id, failure.phase, failure.kind, failure.message
));
}
if let Some(failure) = &target.cleanup_failure {
messages.push(format!(
"irtt-rs: target {} cleanup failed ({} {}): {}",
target.target.id, failure.phase, failure.kind, failure.message
));
}
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" }))
}
struct StreamOutput<'a, W: Write> {
config: OutputConfig,
header_printed: bool,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_event(
&mut self,
event: &ClientEvent,
target: Option<&str>,
update: &EventStatsUpdate,
) -> io::Result<()> {
self.print_header()?;
if let Some(line) = self.config.render_event(event, target, Some(update)) {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_header(&mut self) -> io::Result<()> {
if self.header_printed {
return Ok(());
}
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if self.print_final_summary && self.config.prints_summary() {
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note
}
)
)?;
}
Ok(())
}
}
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
let total_probe_count =
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
// Ask the stats crate what the configuration this run will actually use
// retains; the CLI owns the probe count and the thresholds, not the
// retention model.
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = if estimated_bytes >= GIB {
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
};
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
}
+123
View File
@@ -0,0 +1,123 @@
use std::fmt::Write as _;
use irtt_stats::{Snapshot, TimeStats};
use crate::cmd::format::{
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
format_optional_ns_i128 as format_ns_i128, format_percent,
};
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
let mut out = String::new();
let packets = summary.packets;
let loss = summary.loss;
writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
)
.unwrap();
writeln!(out, " {}", "-".repeat(82)).unwrap();
write_time_row(&mut out, "RTT", &summary.rtt.primary);
if options.verbose {
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
}
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_time_row(
&mut out,
"receive delay",
&summary.one_way_delay.receive_delay,
);
write_time_row(
&mut out,
"server processing",
&summary.server_processing.processing,
);
write_time_row(&mut out, "send call", &summary.send_call);
write_time_row(&mut out, "timer error", &summary.timer_error);
writeln!(out).unwrap();
writeln!(
out,
"packets: sent={} received={} unique={} lost={} loss={}",
packets.packets_sent,
packets.packets_received,
packets.unique_replies,
loss.lost_packets,
format_percent(loss.packet_loss_percent)
)
.unwrap();
if packets.duplicates != 0 || packets.late_packets != 0 {
writeln!(
out,
"replies: duplicates={} ({}) late={} ({})",
packets.duplicates,
format_percent(loss.duplicate_percent),
packets.late_packets,
format_percent(loss.late_packets_percent)
)
.unwrap();
}
writeln!(
out,
"bytes: sent={} received={}",
packets.bytes_sent, packets.bytes_received
)
.unwrap();
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
write!(out, "server:").unwrap();
if let Some(count) = packets.server_packets_received {
write!(out, " received={count}").unwrap();
}
if let Some(window) = packets.server_received_window {
write!(out, " window={window:#x}").unwrap();
}
writeln!(out).unwrap();
}
out
}
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
if value.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.count,
format_ns_i128(value.min_ns),
format_ns_f64(value.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_i128(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
+117
View File
@@ -0,0 +1,117 @@
//! Scalar presentation vocabulary shared by the CLI frontends.
//!
//! Stream output, the run summary, and the TUI display different metrics in
//! different layouts, but they render the same *scalars*: nanosecond-scale
//! durations, percentages, and counts. This module owns those spellings so the
//! frontends cannot drift apart on units, rounding, or how an absent value is
//! rendered.
//!
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
//! deliberately not a general formatting framework: it holds exactly the
//! vocabulary more than one frontend needs.
use std::time::Duration;
#[cfg(feature = "client")]
use irtt_client::SignedDuration;
/// Rendering used wherever a scalar is unavailable.
pub(crate) const ABSENT: &str = "-";
/// Format a nanosecond scalar.
///
/// Unit selection and rounding are one policy for every frontend:
///
/// | magnitude | unit | precision |
/// |-----------|------|-----------|
/// | `< 1µs` | `ns` | whole |
/// | `< 1ms` | `µs` | 1 decimal |
/// | `< 1s` | `ms` | 1 decimal |
/// | `>= 1s` | `s` | 3 decimals |
///
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
/// the unit is selected from the magnitude and the sign is preserved. Values
/// that are not finite render as [`ABSENT`].
pub(crate) fn format_ns_f64(value: f64) -> String {
if !value.is_finite() {
return ABSENT.to_owned();
}
let sign = if value.is_sign_negative() { "-" } else { "" };
let magnitude = value.abs();
if magnitude < 1_000.0 {
format!("{sign}{magnitude:.0}ns")
} else if magnitude < 1_000_000.0 {
format!("{sign}{:.1}µs", magnitude / 1_000.0)
} else if magnitude < 1_000_000_000.0 {
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
} else {
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
}
}
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
pub(crate) fn format_ns_i128(value: i128) -> String {
let sign = if value < 0 { "-" } else { "" };
let magnitude = value.saturating_abs() as f64;
format!("{sign}{}", format_ns_f64(magnitude))
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
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")]
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent)
}
/// Format a duration as a nanosecond scalar.
pub(crate) fn format_duration(value: Duration) -> String {
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
}
/// Format a signed duration as a nanosecond scalar, preserving its sign.
#[cfg(feature = "client")]
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
format_ns_i128(value.as_nanos())
}
/// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`].
pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() {
format!("{value:.2}%")
} else {
ABSENT.to_owned()
}
}
/// Format `value` as a percentage of `total`, capped at 100%.
///
/// A zero total has no percentage to report and renders as [`ABSENT`].
#[cfg(feature = "tui")]
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
ABSENT.to_owned()
} else {
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
}
}
/// Format a scalar count.
#[cfg(feature = "tui")]
pub(crate) fn format_count(value: u64) -> String {
value.to_string()
}
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
#[cfg(feature = "tui")]
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent)
}
fn absent() -> String {
ABSENT.to_owned()
}
+10
View File
@@ -0,0 +1,10 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(any(feature = "client", feature = "tui"))]
mod format;
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "tui")]
pub mod tui;
// Additional command applets belong here behind their own feature gate.
+259
View File
@@ -0,0 +1,259 @@
use std::{
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance};
/// The ordinary IRTT port, used to build the zero-argument default binds.
const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)]
#[command(
name = "irtt-server",
about = "Minimal IRTT-compatible UDP server",
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
)]
pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg(
long,
value_name = "ADDR",
env = "IRTT_SERVER_BIND",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports.\n\nIRTT_SERVER_BIND sets the same thing for deployments that configure by environment (systemd, Docker, ...), and takes exactly one address, the same as one --bind. Leave it unset on ordinary hosts: the wildcard default above is already what you want there. A --bind on the command line overrides it entirely."
)]
pub bind: Vec<SocketAddr>,
/// 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."
)]
pub hmac: Option<String>,
/// Maximum simultaneously live sessions per listener.
#[arg(
long,
value_name = "COUNT",
env = "IRTT_SERVER_MAX_SESSIONS",
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
)]
pub max_sessions: Option<usize>,
/// Maximum echo datagram size a session may negotiate, in bytes.
#[arg(
long,
value_name = "BYTES",
env = "IRTT_SERVER_MAX_PACKET_LENGTH",
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
)]
pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
env = "IRTT_SERVER_MIN_INTERVAL",
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
)]
pub min_interval: Option<Duration>,
/// Echo replies a session may burst before its allowance refills.
#[arg(
long,
value_name = "COUNT",
env = "IRTT_SERVER_BURST",
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
)]
pub burst: Option<u32>,
/// Release a session after this long without an echo request.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
env = "IRTT_SERVER_IDLE_TIMEOUT",
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
)]
pub idle_timeout: Option<Duration>,
/// Maximum test duration a session may negotiate.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_positive_duration,
env = "IRTT_SERVER_MAX_DURATION",
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
)]
pub max_duration: Option<Duration>,
/// How many timestamps this server will provide.
#[arg(
long,
value_name = "MODE",
value_enum,
env = "IRTT_SERVER_TIMESTAMP_ALLOWANCE",
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
)]
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
//
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
// all. A flag's default parser accepts only the literals `true` and
// `false`, so the ordinary container/orchestrator spelling
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
// parsing and the server refused to start. Falsey semantics are clap's own
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
// `n`, `off` and empty are off, anything else is on, and an absent
// variable is off as before. The command-line flag is unaffected: it still
// takes no value and still means "on".
#[arg(
long,
env = "IRTT_SERVER_NO_DSCP",
value_parser = clap::builder::FalseyValueParser::new(),
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
)]
pub no_dscp: bool,
}
/// The command-line spelling of [`TimestampAllowance`].
///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
/// is a reusable crate and has no business depending on an argument parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampAllowanceArg {
/// Honor every requested timestamp placement.
Dual,
/// Provide at most one timestamp instant; both becomes midpoint.
Single,
/// Provide no timestamps.
None,
}
impl From<TimestampAllowanceArg> for TimestampAllowance {
fn from(value: TimestampAllowanceArg) -> Self {
match value {
TimestampAllowanceArg::Dual => Self::Dual,
TimestampAllowanceArg::Single => Self::Single,
TimestampAllowanceArg::None => Self::None,
}
}
}
impl ServerArgs {
/// The addresses this invocation binds: the requested `--bind` list, or
/// the wildcard IRTT port on both address families if none was given.
#[must_use]
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
if self.bind.is_empty() {
vec![
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
]
} else {
self.bind.clone()
}
}
/// Builds the server configuration this invocation asks for.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
let mut config = ServerConfig::default();
if let Some(key) = self.hmac.as_ref() {
config = config.with_hmac_key(key.as_bytes());
}
if let Some(value) = self.max_sessions {
config = config.with_max_sessions(value);
}
if let Some(value) = self.max_packet_length {
config = config.with_max_packet_length(value);
}
if let Some(value) = self.min_interval {
config = config.with_min_send_interval(value);
}
if let Some(value) = self.burst {
config = config.with_burst_allowance(value);
}
if let Some(value) = self.idle_timeout {
config = config.with_idle_timeout(value);
}
if let Some(value) = self.max_duration {
config = config.with_max_test_duration(value);
}
if let Some(value) = self.timestamp_allowance {
config = config.with_timestamp_allowance(value.into());
}
// A negative capability flag, so absence is the ordinary enabled state
// and the library default is what an unset flag leaves in place.
if self.no_dscp {
config = config.with_dscp_allowed(false);
}
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" {
return Ok(Duration::ZERO);
}
parse_duration_units(input)
}
/// Parses a duration that must be positive, where absence means "no limit".
fn parse_positive_duration(input: &str) -> Result<Duration, String> {
let value = if input == "0" {
Duration::ZERO
} else {
parse_duration_units(input)?
};
if value.is_zero() {
return Err(
"duration must be greater than zero; omit the option for no maximum".to_owned(),
);
}
Ok(value)
}
fn parse_duration_units(input: &str) -> Result<Duration, 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() {
return Err("duration must include a value and a unit: ms, s, or m".to_owned());
}
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
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"
)),
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod args;
pub mod run;
pub use args::ServerArgs;
pub use run::run_server;
+107
View File
@@ -0,0 +1,107 @@
use std::{
net::SocketAddr,
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use irtt_server::{address_family_available, ServerSet, ServerSetError};
use super::args::ServerArgs;
/// How often the shutdown future samples the process-wide signal flag.
///
/// The flag a signal handler sets has no async wake mechanism, so the future
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
/// after that future becomes ready, including fanning it out to the listeners.
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Runs the configured listeners until the process is asked to shut down.
///
/// The applet owns exactly one current-thread Tokio runtime and one
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
/// is an ordinary set of one and takes the same path as a set of several: one
/// construction, one shutdown future, one supervision rule. Listener
/// concurrency is async I/O concurrency and needs no worker threads.
pub fn run_server(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(serve(args, shutdown_requested))
}
async fn serve(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
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 {
Ok(set) => set,
Err(ServerSetError::ListenerSetup { addr, source }) => {
let family_available = address_family_available(addr);
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
Some(remaining) => {
eprintln!(
"irtt-server: default listener {addr} unavailable ({source}); \
continuing with the remaining default listener"
);
// `remaining`'s lone survivor is bound exactly as it
// would be alone: the address family that just failed
// has no local support at all, so nothing on that family
// could be competing for the surviving listener's port.
ServerSet::bind(remaining, config).await?
}
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
}
}
Err(err) => return Err(err.into()),
};
// Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started.
for addr in set.local_addrs() {
eprintln!("irtt-server: listening on {addr}");
}
set.run(shutdown_signal(shutdown_requested)).await?;
Ok(())
}
/// Whether a listener-setup failure for `failed_addr` should be retried with
/// a reduced bind set instead of failing the whole invocation, and if so,
/// what to retry with.
///
/// Only the implicit zero-argument default gets this treatment, and only
/// when `failed_addr`'s own address family has no local support on this host
/// at all (`family_available` is `false`) — not merely because its specific
/// port was unavailable for some other reason: already in use, permission
/// denied, no safe wildcard reply-source path, or anything else
/// `ServerSet::bind` can fail with. Those are real configuration or
/// environment problems this invocation should fail on, not silently work
/// around by dropping a listener the operator asked for. An explicit
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
/// regardless.
fn implicit_default_fallback(
is_implicit_default: bool,
family_available: bool,
original_binds: &[SocketAddr],
failed_addr: SocketAddr,
) -> Option<Vec<SocketAddr>> {
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
return None;
}
let remaining: Vec<SocketAddr> = original_binds
.iter()
.copied()
.filter(|&addr| addr != failed_addr)
.collect();
(!remaining.is_empty()).then_some(remaining)
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
}
}
+76
View File
@@ -0,0 +1,76 @@
use std::time::Duration;
use clap::Parser;
use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection,
};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", 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(
value_name = "TARGET",
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"
)]
pub targets: Vec<TargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
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."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
/// Validate the selected targets and prepare the managed run.
///
/// 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 async fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: false,
},
)
.await
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
File renamed without changes.
+278
View File
@@ -0,0 +1,278 @@
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, request_managed_stop_once},
worker::ManagedWorker,
},
};
use crossterm::{
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
terminal,
};
use futures_core::Stream;
use irtt_client::{
managed::{
ManagedClient, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy,
ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetLifecycle,
},
ClientEvent,
};
use ratatui::layout::Rect;
use tokio::{
sync::{broadcast::error::RecvError, watch},
time::{sleep, Instant},
};
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 = 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(());
}
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::with_target_labels(
TuiConfig::from_args(&args, &setup),
setup.targets.iter().map(|target| target.label.clone()),
);
state.set_status(TuiStatus::Opening);
terminal.draw(&state)?;
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());
terminal.draw(&state)?;
return Err(Box::new(error));
}
};
// Both subscriptions exist before the measurement task can be polled.
let mut events = handle.subscribe()?;
let mut status = handle.subscribe_status();
let worker = match ManagedWorker::start(task, handle.clone()) {
Ok(worker) => worker,
Err(error) => {
state.set_run_error(error.to_string());
terminal.draw(&state)?;
return Err(error.into());
}
};
let completion = worker.join();
tokio::pin!(completion);
// Input and rendering stay on the frontend executor. EventStream's drop
// wakes its reader; no terminal task can outlive the terminal guard.
let mut input = EventStream::new();
let render = sleep(RENDER_INTERVAL);
tokio::pin!(render);
let mut interrupted = false;
let mut stop_requested = false;
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;
let outcome = loop {
let mut force_render = false;
// Default select fairness lets input, status and rendering progress
// even when measurement events are continuously ready.
tokio::select! {
outcome = &mut completion => break outcome,
_ = shutdown.changed(), if !interrupted => {
interrupted = true;
state.set_status(TuiStatus::Interrupted);
force_render = true;
}
changed = status.changed(), if !status_closed => {
status_closed = changed.is_err();
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,
),
Err(RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count);
state.mark_dropped_managed_events(dropped_events);
force_render = true;
}
Err(RecvError::Closed) => events_closed = true,
},
event = poll_fn(|cx| std::pin::Pin::new(&mut input).poll_next(cx)) => {
let event = event.ok_or_else(|| io::Error::new(
io::ErrorKind::UnexpectedEof, "terminal input closed",
))??;
match event {
Event::Resize(_, _) => force_render = true,
Event::Key(key) if key.kind != KeyEventKind::Release => {
match key.code {
KeyCode::Char('q') | KeyCode::Char('c')
if key.code == KeyCode::Char('q')
|| key.modifiers.contains(KeyModifiers::CONTROL) => {
interrupted = true;
state.set_status(TuiStatus::Interrupted);
force_render = true;
}
_ => {
let (width, height) = terminal::size()?;
force_render = state.handle_key(
key.code, Rect::new(0, 0, width, height),
);
}
}
}
_ => {}
}
}
_ = &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.
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
}
};
state.set_status(TuiStatus::Closing);
terminal.draw(&state)?;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => {
state.set_run_error(error.to_string());
terminal.draw(&state)?;
return Err(error);
}
};
drain_final_events(&mut events, &mut dropped_events, |event| {
process_tui_event(event, &mut state);
Ok::<(), std::convert::Infallible>(())
})?;
state.mark_dropped_managed_events(dropped_events);
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() {
state.process_target_outcome(target);
}
interrupted |= *shutdown.borrow();
let error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => (!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 {
state.set_run_error(error.clone());
terminal.draw(&state)?;
return Err(error.into());
}
state.set_status(TuiStatus::Complete);
terminal.draw(&state)?;
Ok(())
}
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
match event {
ManagedEvent::Client { target, event } => {
let force_render = matches!(
event,
ClientEvent::SessionStarted(_)
| ClientEvent::NoTestCompleted(_)
| ClientEvent::SessionClosed { .. }
| ClientEvent::Warning { .. }
);
state.process_target_event(&target, &event);
force_render
}
ManagedEvent::TargetFinished { outcome } => {
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
+139
View File
@@ -0,0 +1,139 @@
#[cfg(feature = "server")]
use std::sync::{atomic::AtomicBool, Arc};
use std::{env, ffi::OsString, process::ExitCode};
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
#[cfg(feature = "server")]
use crate::signal::install_signal_handler;
/// Entry point for the `irtt-rs` multicall dispatcher binary.
///
/// Chooses an applet by subcommand or by recognized argv0 and may reference
/// every applet enabled by the active feature set.
pub fn dispatcher_main() -> ExitCode {
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
}
/// Entry point for the dedicated `irtt-client` binary.
///
/// Runs the stream client directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the client.
#[cfg(feature = "client")]
pub fn client_main() -> ExitCode {
run_with_exit_code("irtt-client", || {
run_client_applet(env::args_os().collect())
})
}
/// Entry point for the dedicated `irtt-tui` binary.
///
/// Runs the terminal UI directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the TUI.
#[cfg(feature = "tui")]
pub fn tui_main() -> ExitCode {
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
}
/// Entry point for the dedicated `irtt-server` binary.
///
/// Runs the server directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the server.
#[cfg(feature = "server")]
pub fn server_main() -> ExitCode {
run_with_exit_code("irtt-server", || {
run_server_applet(env::args_os().collect())
})
}
fn run_with_exit_code(
program: &str,
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> ExitCode {
match f() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("{program}: {err}");
ExitCode::FAILURE
}
}
}
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let (requested, argv) = match dispatch_from_argv(argv)? {
AppletDispatch::Run { applet, argv } => (applet, argv),
AppletDispatch::Help(help) => {
print!("{help}");
return Ok(());
}
};
match requested {
RequestedApplet::Client => run_client_applet(argv),
RequestedApplet::Tui => run_tui_applet(argv),
RequestedApplet::Server => run_server_applet(argv),
}
}
/// Installs the shutdown signal handler for synchronous applets and hands the
/// resulting flag to `f`.
#[cfg(feature = "server")]
fn with_shutdown_flag(
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> {
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
f(shutdown_requested.as_ref())
}
#[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);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::cmd::client::run_stream(args, shutdown))
}
#[cfg(not(feature = "client"))]
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[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);
crate::cmd::server::run_server(args, shutdown_requested)
})
}
#[cfg(not(feature = "server"))]
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("server applet is not available; rebuild with the server feature".into())
}
#[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);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::cmd::tui::run_tui(args, shutdown))
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
@@ -2,5 +2,7 @@
pub mod applet; pub mod applet;
pub mod cmd; pub mod cmd;
#[doc(hidden)]
pub mod entry;
pub mod shared; pub mod shared;
pub mod signal; pub mod signal;
@@ -1,11 +1,23 @@
use std::time::Duration; use std::{fmt, time::Duration};
use clap::ValueEnum; use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH}; use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{Clock, ReceivedStats, StampAt}; use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Debug, Clone, clap::Args)] #[derive(Clone, clap::Args)]
pub struct CommonClientArgs { pub struct CommonClientArgs {
/// 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,
/// Probe interval. /// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)] #[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration, pub interval: Duration,
@@ -15,7 +27,7 @@ pub struct CommonClientArgs {
pub length: u32, pub length: u32,
/// HMAC key. /// HMAC key.
#[arg(long)] #[arg(long, value_parser = parse_hmac_key)]
pub hmac: Option<String>, pub hmac: Option<String>,
/// Clock mode to request. /// Clock mode to request.
@@ -63,6 +75,26 @@ pub struct CommonClientArgs {
pub loose: bool, pub loose: bool,
} }
impl fmt::Debug for CommonClientArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommonClientArgs")
.field("ipv4", &self.ipv4)
.field("ipv6", &self.ipv6)
.field("dual_stack", &self.dual_stack)
.field("interval", &self.interval)
.field("length", &self.length)
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
.field("clock", &self.clock)
.field("tstamp", &self.tstamp)
.field("stats", &self.stats)
.field("server_fill", &self.server_fill)
.field("dscp", &self.dscp)
.field("ttl", &self.ttl)
.field("loose", &self.loose)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg { pub enum ClockArg {
Wall, Wall,
@@ -189,6 +221,13 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
Ok(length) Ok(length)
} }
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
pub fn parse_server_fill(input: &str) -> Result<String, String> { pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() { if input.is_empty() {
return Err("server fill must not be empty".to_owned()); return Err("server fill must not be empty".to_owned());
@@ -0,0 +1,72 @@
use std::time::Duration;
use irtt_client::{
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 {
address_family: self.address_family(),
open: OpenPolicy {
negotiation: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
..Default::default()
},
request: SessionRequest {
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
server_fill: self.server_fill.clone(),
..Default::default()
},
auth: self
.hmac
.as_ref()
.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key.as_bytes()))
}),
socket: SocketConfig {
ttl: self.ttl,
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
+20
View File
@@ -0,0 +1,20 @@
pub mod args;
pub mod config;
pub mod prepare;
pub mod session;
pub mod targets;
pub(crate) mod worker;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::expected_probe_count;
pub use prepare::{
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
};
pub use targets::{
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
TargetPreparation, TargetSpec,
};
@@ -0,0 +1,133 @@
//! One preparation step from parsed arguments to a runnable managed setup.
//!
//! Both client applets accept the same target and pacing arguments and then
//! have to turn them into the same three things: a validated target set, one
//! shared [`ClientConfig`] template, and a [`ManagedPacing`]. Doing that
//! separately in each applet is how the CLI ended up deriving its client
//! configuration from an arbitrary "primary" target whose address the managed
//! driver immediately replaced per target.
use std::time::Duration;
use irtt_client::{
managed::{ManagedClientConfig, ManagedCompletionPolicy, ManagedPacing},
ClientConfig,
};
use super::{
args::CommonClientArgs,
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
PreparedTarget,
};
/// Capacity of the lossy managed presentation event channel.
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Target and pacing arguments common to the client applets.
///
/// Duration is deliberately absent: the applets disagree about what a duration
/// means and what its default is, so each keeps its own.
#[derive(Debug, Clone, Copy)]
pub struct TargetSelection<'a> {
pub targets: &'a [TargetArg],
pub pacing: GroupPacingArg,
pub stdin_controlled: bool,
}
/// Everything a managed run needs, prepared and validated together.
#[derive(Debug, Clone)]
pub struct ManagedRunSetup {
/// Validated targets, in the order the user gave them.
pub targets: Vec<PreparedTarget>,
/// Shared client configuration template.
///
/// The managed driver supplies each target's address and authentication
/// from its [`ManagedTargetConfig`](irtt_client::managed::ManagedTargetConfig),
/// so this template carries no target of its own.
pub client: ClientConfig,
/// Send coordination across active targets.
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 {
/// Number of validated targets.
pub fn target_count(&self) -> usize {
self.targets.len()
}
/// Whether this run drives more than one target.
pub fn is_multi_target(&self) -> bool {
self.targets.len() > 1
}
/// Managed target configurations, in argument order.
pub fn managed_targets(&self) -> Vec<irtt_client::managed::ManagedTargetConfig> {
self.targets
.iter()
.map(|target| target.managed.clone())
.collect()
}
/// Managed driver configuration for this run.
pub fn managed_config(&self) -> ManagedClientConfig {
let target_count = self.target_count();
let mut config = ManagedClientConfig {
client: self.client.clone(),
pacing: self.pacing,
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
event_capacity: MANAGED_EVENT_CAPACITY,
outcome_history_limit: target_count,
max_live_target_generations: target_count,
..ManagedClientConfig::default()
};
if self.stdin_controlled {
config.completion = ManagedCompletionPolicy::ExplicitStop;
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
}
config
}
}
/// Validate the selected targets and build the shared run setup.
///
/// 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 async fn prepare_managed_run(
common: &CommonClientArgs,
duration: Duration,
selection: TargetSelection<'_>,
) -> Result<ManagedRunSetup, String> {
let specs = if selection.stdin_controlled {
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
return Err(format!(
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
));
}
super::targets::target_specs_with_empty(selection.targets, true)?
} else {
target_specs(selection.targets)?
};
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),
pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled,
preparation,
})
}
@@ -0,0 +1,61 @@
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
pub(crate) fn drain_final_events<E>(
events: &mut ManagedEventSubscription,
dropped_events: &mut u64,
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
) -> Result<(), E> {
loop {
match events.try_recv() {
Ok(event) => process(event)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn peer_close_run_error(
continuous: bool,
interrupted: bool,
peer_closed_target_outcomes: u64,
) -> Option<String> {
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
return None;
}
let sessions = if peer_closed_target_outcomes == 1 {
"target session"
} else {
"target sessions"
};
Some(format!(
"continuous run ended because of peer closure ({peer_closed_target_outcomes} {sessions})"
))
}
pub fn request_managed_stop_for_peer_close(
continuous: bool,
interrupted: bool,
peer_closed_target_outcomes: u64,
stop_requested: &mut bool,
) -> bool {
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
return false;
}
request_managed_stop_once(stop_requested)
}
pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
if *stop_requested {
return false;
}
*stop_requested = true;
true
}
@@ -0,0 +1,426 @@
use std::{
collections::{HashMap, HashSet},
fmt, io,
net::{IpAddr, SocketAddr},
};
use clap::ValueEnum;
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
AddressFamily, Authentication, HmacKey,
};
/// One raw positional target captured by Clap.
///
/// Parsing happens during run preparation so invalid target syntax never makes
/// Clap include the original argument (which may carry an HMAC key) in an
/// error diagnostic.
#[derive(Clone, PartialEq, Eq)]
pub struct TargetArg {
input: String,
}
impl TargetArg {
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
}
}
}
impl fmt::Debug for TargetArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TargetArg(<redacted>)")
}
}
/// Capture one positional target without exposing it through Clap diagnostics.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
Ok(TargetArg::new(input))
}
#[derive(Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub addr: String,
pub auth: TargetAuth,
}
impl fmt::Debug for TargetSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetSpec")
.field("label", &self.label)
.field("addr", &self.addr)
.field("auth", &self.auth)
.finish()
}
}
#[derive(Clone)]
pub struct PreparedTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
impl fmt::Debug for PreparedTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
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()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
Burst,
}
impl From<GroupPacingArg> for ManagedPacing {
fn from(value: GroupPacingArg) -> Self {
match value {
GroupPacingArg::Staggered => Self::Staggered,
GroupPacingArg::Burst => Self::Burst,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetParseError {
EmptyLabel,
EmptyAddress,
InvalidEscape,
EmptyTarget,
}
impl fmt::Display for TargetParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::EmptyLabel => "target label must not be empty",
Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty",
};
f.write_str(message)
}
}
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
let mut parts = Vec::new();
let mut start = 0;
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
parts.push(&input[start..index]);
start = index + ch.len_utf8();
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
parts.push(&input[start..]);
Ok(parts)
}
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn unescape(input: &str) -> Result<String, TargetParseError> {
let mut output = String::with_capacity(input.len());
let mut escaped = false;
for ch in input.chars() {
if escaped {
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
return Err(TargetParseError::InvalidEscape);
}
output.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
output.push(ch);
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(output)
}
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if input[index..].starts_with("@hmac=") {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn parse_target_syntax(
input: &str,
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
if input.is_empty() {
return Err(TargetParseError::EmptyTarget);
}
let (main, auth) = match hmac_modifier(input)? {
Some(index) => {
let value = unescape(&input[index + "@hmac=".len()..])?;
let auth = if value.is_empty() {
TargetAuth::Override(Authentication::Unauthenticated)
} else {
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
};
(&input[..index], auth)
}
None => (input, TargetAuth::Inherit),
};
let (label, address) = match first_unescaped(main, '=')? {
None => (None, unescape(main)?),
Some(index) => {
let label = unescape(&main[..index])?;
let address = unescape(&main[index + '='.len_utf8()..])?;
(Some(label), address)
}
};
if label.as_deref().is_some_and(str::is_empty) {
return Err(TargetParseError::EmptyLabel);
}
if address.is_empty() {
return Err(TargetParseError::EmptyAddress);
}
Ok((label, address, auth))
}
pub(crate) fn target_specs_with_empty(
targets: &[TargetArg],
allow_empty: bool,
) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
for (index, target) in targets.iter().enumerate() {
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
let label = match explicit_label {
Some(label) => label,
None => {
let count = unlabeled_counts.entry(addr.clone()).or_default();
*count += 1;
if *count == 1 {
addr.clone()
} else {
format!("{}#{}", addr, *count)
}
}
};
specs.push(TargetSpec { label, addr, auth });
}
if specs.is_empty() && !allow_empty {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err("duplicate target label".to_owned());
}
}
Ok(specs)
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
/// 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(),
}
}
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.
///
/// Commas frame stdin elements only; positional target arguments do not use
/// this framing and may therefore contain raw commas.
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<TargetSpec>, String> {
if record == "[]" {
return Ok(Vec::new());
}
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
if elements.len() > maximum_targets {
return Err(format!(
"target set exceeds the {maximum_targets}-target limit"
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
target_specs_with_empty(&args, true)
}
@@ -0,0 +1,67 @@
use std::{io, thread, time::Duration};
use irtt_client::managed::{ManagedClientHandle, ManagedClientTask, ManagedOutcome};
use tokio::sync::oneshot;
/// Keeps measurement scheduling separate from frontend output and statistics.
/// Drop also stops and joins on frontend errors or cancellation.
pub(crate) struct ManagedWorker {
handle: ManagedClientHandle,
thread: Option<thread::JoinHandle<()>>,
completion: oneshot::Receiver<io::Result<ManagedOutcome>>,
}
impl ManagedWorker {
pub(crate) fn start(task: ManagedClientTask, handle: ManagedClientHandle) -> io::Result<Self> {
let (completed, completion) = oneshot::channel();
let thread = thread::Builder::new()
.name("irtt-managed".to_owned())
.spawn(move || {
// Construct and destroy the runtime on its owning thread, including
// startup failures; never drop a runtime inside the frontend executor.
let outcome = (|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runtime.block_on(task)
}));
runtime.shutdown_timeout(Duration::from_millis(100));
match outcome {
Ok(outcome) => Ok(outcome),
Err(panic) => std::panic::resume_unwind(panic),
}
})();
let _ = completed.send(outcome);
})?;
Ok(Self {
handle,
thread: Some(thread),
completion,
})
}
pub(crate) async fn join(mut self) -> Result<ManagedOutcome, Box<dyn std::error::Error>> {
let outcome = (&mut self.completion).await;
self.join_thread()?;
Ok(outcome
.map_err(|_| io::Error::other("managed worker completed without an outcome"))??)
}
fn join_thread(&mut self) -> io::Result<()> {
self.thread
.take()
.expect("managed worker is joined once")
.join()
.map_err(|_| io::Error::other("managed worker thread panicked"))
}
}
impl Drop for ManagedWorker {
fn drop(&mut self) {
if self.thread.is_some() {
drop(self.handle.stop());
let _ = self.join_thread();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(any(feature = "client", feature = "tui"))]
pub mod client;
+22
View File
@@ -0,0 +1,22 @@
#[cfg(feature = "server")]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
#[cfg(feature = "server")]
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
/// Preserve ctrlc's SIGINT/SIGTERM policy while waking the async frontends.
#[cfg(any(feature = "client", feature = "tui"))]
pub fn install_async_signal_handler() -> Result<tokio::sync::watch::Receiver<bool>, ctrlc::Error> {
let (shutdown, receiver) = tokio::sync::watch::channel(false);
ctrlc::set_handler(move || {
shutdown.send_replace(true);
})?;
Ok(receiver)
}
-44
View File
@@ -1,44 +0,0 @@
[package]
name = "irtt-cli"
version = "0.3.0"
description = "Command-line IRTT-compatible client"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
doc = false
[[bin]]
name = "irtt-rs"
path = "src/main.rs"
[[bin]]
name = "irtt-cli"
path = "src/main.rs"
required-features = ["client"]
[[bin]]
name = "irtt-tui"
path = "src/main.rs"
required-features = ["tui"]
[features]
default = ["client", "stats"]
client = ["dep:irtt-client", "dep:irtt-proto"]
stats = ["client", "dep:irtt-stats"]
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.28.1", optional = true }
ctrlc = "3.5.2"
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.29.0", optional = true }
-85
View File
@@ -1,85 +0,0 @@
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppletAvailability {
Client,
Tui,
Server,
None,
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0)
{
"irtt-cli" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub const fn default_applet_for_features(
client_enabled: bool,
tui_enabled: bool,
server_enabled: bool,
) -> AppletAvailability {
if client_enabled {
AppletAvailability::Client
} else if tui_enabled {
AppletAvailability::Tui
} else if server_enabled {
AppletAvailability::Server
} else {
AppletAvailability::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-cli"),
Some(RequestedApplet::Client)
);
assert_eq!(
detect_applet_from_argv0("irtt-tui"),
Some(RequestedApplet::Tui)
);
assert_eq!(
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn default_applet_prefers_client_then_tui() {
assert_eq!(
default_applet_for_features(true, true, false),
AppletAvailability::Client
);
assert_eq!(
default_applet_for_features(false, true, false),
AppletAvailability::Tui
);
assert_eq!(
default_applet_for_features(false, false, false),
AppletAvailability::None
);
}
}
-136
View File
@@ -1,136 +0,0 @@
use std::time::Duration;
use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig;
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(
long,
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 only when interrupted."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
pub output: OutputMode,
/// Include extra fields in human output.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
/// Readable terminal output with a final summary.
Human,
/// Parseable full event fields.
Machine,
/// Simple key=value-ish event stream.
Simple,
/// RTT microseconds only.
RttUs,
}
impl OutputMode {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Human)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.to_client_config();
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn output_mode_summary_policy_is_human_only() {
assert!(OutputMode::Human.prints_summary());
assert!(!OutputMode::Simple.prints_summary());
assert!(!OutputMode::Machine.prints_summary());
assert!(!OutputMode::RttUs.prints_summary());
}
}
-12
View File
@@ -1,12 +0,0 @@
pub mod args;
pub mod output;
pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, OutputMode};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use run::run_stream;
-656
View File
@@ -1,656 +0,0 @@
use std::{
fmt::Write as _,
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_client::{
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
};
use super::args::OutputMode;
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
if matches!(event, ClientEvent::EchoSent { .. }) {
return None;
}
match mode {
OutputMode::RttUs => format_rtt_us(event),
OutputMode::Human => Some(format_human_event(event, None)),
OutputMode::Machine => Some(format_machine(event)),
OutputMode::Simple => Some(format_simple(event)),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HumanEventStats {
pub contributed_sample: bool,
pub ipdv_pairs: Vec<HumanIpdvPair>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HumanIpdvPair {
pub previous_seq: u32,
pub current_seq: u32,
pub rtt_ipdv: Duration,
pub send_ipdv: Option<Duration>,
pub receive_ipdv: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HumanOutputOptions {
pub verbose: bool,
}
#[cfg(feature = "stats")]
impl From<irtt_stats::EventStatsUpdate> for HumanEventStats {
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
Self {
contributed_sample: value.contributed_sample,
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
}
}
}
#[cfg(feature = "stats")]
impl From<irtt_stats::IpdvPairUpdate> for HumanIpdvPair {
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
Self {
previous_seq: value.previous_seq,
current_seq: value.current_seq,
rtt_ipdv: value.rtt_ipdv,
send_ipdv: value.send_ipdv,
receive_ipdv: value.receive_ipdv,
}
}
}
pub fn format_human_event(event: &ClientEvent, stats: Option<HumanEventStats>) -> String {
format_human_event_with_options(event, stats, HumanOutputOptions::default())
}
pub fn format_human_event_with_options(
event: &ClientEvent,
stats: Option<HumanEventStats>,
options: HumanOutputOptions,
) -> String {
let stats = stats.as_ref();
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
rtt,
server_timing,
one_way,
received_stats,
..
} => {
let mut out = format!("seq={seq}");
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " proc={}", format_duration(processing)).unwrap();
}
if options.verbose {
write_human_received_stats(&mut out, *received_stats);
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
one_way,
received_stats,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
}
write_human_received_stats(&mut out, *received_stats);
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn format_rtt_us(event: &ClientEvent) -> Option<String> {
match event {
ClientEvent::EchoReply { rtt, .. } => Some(signed_duration_us(rtt.effective).to_string()),
_ => None,
}
}
fn format_machine(event: &ClientEvent) -> String {
let mut out = String::new();
match event {
ClientEvent::SessionStarted {
remote,
token,
negotiated,
at,
} => {
write_common(&mut out, "session_started");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::NoTestCompleted {
remote,
negotiated,
at,
} => {
write_common(&mut out, "no_test_completed");
write_remote(&mut out, *remote);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::SessionClosed { remote, token, at } => {
write_common(&mut out, "session_closed");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
}
ClientEvent::EchoSent { .. } => {}
ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "echo_reply");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
write_rtt(&mut out, rtt);
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
}
ClientEvent::EchoLoss { seq, sent_at, .. } => {
write_common(&mut out, "loss");
write_seq(&mut out, *seq);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
out.push_str(" warning=loss");
}
ClientEvent::DuplicateReply {
seq,
remote,
received_at,
bytes: _,
} => {
write_common(&mut out, "duplicate");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
out.push_str(" warning=duplicate");
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "late");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write!(out, " highest_seen={highest_seen}").unwrap();
if let Some(sent_at) = sent_at {
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
}
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
if let Some(rtt) = rtt {
write_rtt(&mut out, rtt);
}
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
out.push_str(" warning=late");
}
ClientEvent::Warning { kind, message, at } => {
write_common(&mut out, "warning");
write_wall(&mut out, "at", at.wall);
write!(
out,
" warning_kind={} message={}",
warning_kind(*kind),
escape_value(message)
)
.unwrap();
}
}
out
}
fn format_simple(event: &ClientEvent) -> String {
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
remote,
rtt,
server_timing,
bytes: _,
..
} => {
let mut out = format!(
"reply seq={seq} remote={remote} rtt_us={}",
signed_duration_us(rtt.effective)
);
if rtt.adjusted.is_some() {
write!(out, " raw_rtt_us={}", duration_us(rtt.raw)).unwrap();
}
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt_us={}", signed_duration_us(rtt.effective)).unwrap();
}
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn write_common(out: &mut String, event: &str) {
write!(out, "event={event}").unwrap();
}
fn write_seq(out: &mut String, seq: u32) {
write!(out, " seq={seq}").unwrap();
}
fn write_remote(out: &mut String, remote: SocketAddr) {
write!(out, " remote={remote}").unwrap();
}
fn write_token(out: &mut String, token: u64) {
write!(out, " token={token:#x}").unwrap();
}
fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
write!(out, " {key}={}", duration.as_nanos()).unwrap();
}
}
fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
write!(
out,
" duration_ns={} interval_ns={} payload_length={}",
negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
)
.unwrap();
}
fn write_rtt(out: &mut String, rtt: &RttSample) {
write!(
out,
" raw_rtt_us={} effective_rtt_us={}",
duration_us(rtt.raw),
signed_duration_us(rtt.effective)
)
.unwrap();
if let Some(adjusted) = rtt.adjusted {
write!(out, " adjusted_rtt_us={}", signed_duration_us(adjusted)).unwrap();
}
}
fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
if let Some(timing) = timing {
write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
if let Some(processing) = timing.processing {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
}
}
fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
if let Some(one_way) = one_way {
if let Some(value) = one_way.client_to_server {
write!(out, " client_to_server_us={}", signed_duration_us(value)).unwrap();
}
if let Some(value) = one_way.server_to_client {
write!(out, " server_to_client_us={}", signed_duration_us(value)).unwrap();
}
}
}
fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received_count={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_received_window={window:#x}").unwrap();
}
}
}
fn write_packet_meta(out: &mut String, meta: PacketMeta) {
write_optional_u8(out, "traffic_class", meta.traffic_class);
write_optional_u8(out, "dscp", meta.dscp);
write_optional_u8(out, "ecn", meta.ecn);
match meta.kernel_rx_timestamp {
Some(timestamp) => write_wall(out, "kernel_rx_ns", timestamp),
None => write!(out, " kernel_rx_ns=none").unwrap(),
}
}
fn write_human_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
match one_way {
Some(one_way) => {
write!(
out,
" rd={} sd={}",
format_optional_signed_duration(one_way.server_to_client),
format_optional_signed_duration(one_way.client_to_server)
)
.unwrap();
}
None => out.push_str(" rd=n/a sd=n/a"),
}
}
fn write_human_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_window={window:#x}").unwrap();
}
}
}
fn format_human_ipdv(stats: Option<&HumanEventStats>, seq: u32) -> String {
let Some(stats) = stats else {
return "n/a".to_owned();
};
let pair = stats
.ipdv_pairs
.iter()
.find(|pair| pair.current_seq == seq)
.or_else(|| {
stats
.ipdv_pairs
.iter()
.find(|pair| pair.previous_seq == seq)
});
pair.map(|pair| format_duration(pair.rtt_ipdv))
.unwrap_or_else(|| "n/a".to_owned())
}
fn write_optional_u8(out: &mut String, key: &str, value: Option<u8>) {
match value {
Some(value) => write!(out, " {key}={value}").unwrap(),
None => write!(out, " {key}=none").unwrap(),
}
}
fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
if let Some(value) = value {
write!(out, " {key}={value}").unwrap();
}
}
fn duration_us(duration: Duration) -> u128 {
duration.as_micros()
}
fn signed_duration_us(duration: SignedDuration) -> i128 {
duration.as_micros()
}
fn format_optional_signed_duration(duration: Option<SignedDuration>) -> String {
duration
.map(format_signed_duration)
.unwrap_or_else(|| "n/a".to_owned())
}
fn format_duration(duration: Duration) -> String {
format_ns(duration.as_nanos() as f64)
}
fn format_signed_duration(duration: SignedDuration) -> String {
format_signed_ns(duration.as_nanos() as f64)
}
fn format_signed_ns(ns: f64) -> String {
if ns < 0.0 {
format!("-{}", format_ns(-ns))
} else {
format_ns(ns)
}
}
fn format_ns(ns: f64) -> String {
if ns < 1_000.0 {
format!("{ns:.0}ns")
} else if ns < 1_000_000.0 {
format!("{:.1}µs", ns / 1_000.0)
} else if ns < 1_000_000_000.0 {
format!("{:.1}ms", ns / 1_000_000.0)
} else {
format!("{:.3}s", ns / 1_000_000_000.0)
}
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
WarningKind::WrongToken => "wrong_token",
WarningKind::UntrackedReply => "untracked_reply",
_ => "unknown",
}
}
fn escape_value(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(' ', "\\s")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event() -> ClientEvent {
ClientEvent::EchoReply {
seq: 7,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: Some(1_000),
receive_mono_ns: Some(2_000),
send_wall_ns: Some(301_000),
send_mono_ns: Some(302_000),
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(400_000)),
server_to_client: Some(SignedDuration::from_nanos(500_000)),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(9),
window: Some(0x7),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn rtt_us_prints_only_effective_reply_rtt() {
assert_eq!(
format_event(&reply_event(), OutputMode::RttUs),
Some("1200".to_owned())
);
assert_eq!(
format_event(
&ClientEvent::Warning {
kind: WarningKind::WrongToken,
message: "bad".to_owned(),
at: ClientTimestamp::now()
},
OutputMode::RttUs
),
None
);
}
#[test]
fn machine_prints_stable_key_value_fields() {
let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
assert!(line.starts_with("event=echo_reply "));
assert!(line.contains("seq=7"));
assert!(line.contains("remote=127.0.0.1:2112"));
assert!(line.contains("client_send_wall_ns=1000000000"));
assert!(line.contains("client_receive_wall_ns=1001500000"));
assert!(line.contains("raw_rtt_us=1500"));
assert!(line.contains("adjusted_rtt_us=1200"));
assert!(line.contains("effective_rtt_us=1200"));
assert!(line.contains("server_processing_us=300"));
assert!(line.contains("server_received_count=9"));
}
#[test]
fn simple_and_human_use_readable_reply_lines() {
assert_eq!(
format_event(&reply_event(), OutputMode::Simple),
Some(
"reply seq=7 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
.to_owned()
)
);
let human = format_event(&reply_event(), OutputMode::Human).unwrap();
assert!(human.starts_with("seq=7 rtt=1.2ms"));
assert!(human.contains("rd=500.0µs"));
assert!(human.contains("sd=400.0µs"));
assert!(human.contains("ipdv=n/a"));
assert!(human.contains("proc=300.0µs"));
assert!(!human.contains("rtt_us="));
}
#[test]
fn echo_sent_is_not_formatted_for_stream_outputs() {
let ts = test_timestamp(Duration::from_secs(1));
let event = ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: ts.mono,
sent_at: ts,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
};
assert!(format_event(&event, OutputMode::RttUs).is_none());
assert!(format_event(&event, OutputMode::Human).is_none());
assert!(format_event(&event, OutputMode::Machine).is_none());
assert!(format_event(&event, OutputMode::Simple).is_none());
}
}
-485
View File
@@ -1,485 +0,0 @@
use std::{
io::{self, Write},
sync::atomic::AtomicBool,
};
use irtt_client::ClientEvent;
#[cfg(all(test, feature = "stats"))]
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{
args::{ClientArgs, OutputMode},
output::{format_event, format_human_event_with_options, HumanOutputOptions},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested, session::should_print_final_summary, ClientSession,
};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
#[cfg(feature = "stats")]
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
#[cfg(feature = "stats")]
const MIB: u64 = 1024 * 1024;
#[cfg(feature = "stats")]
const GIB: u64 = 1024 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
eprintln!("{warning}");
}
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
stream_output.print_events(&events)?;
let mut interrupted = false;
while session.should_continue(shutdown_requested) {
let events = session.step(shutdown_requested)?;
stream_output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
session.sleep_until_next_send();
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if session.should_drain_final(interrupted) {
session.drain_final(|events| {
let _ = stream_output.print_events(events);
})?;
}
let events = session.poll_timeouts()?;
stream_output.print_events(&events)?;
let events = session.close()?;
stream_output.print_events(&events)?;
let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
stream_output.print_summary()?;
stream_output.out.flush()?;
Ok(())
}
struct StreamOutput<'a, W: Write> {
mode: OutputMode,
human_options: HumanOutputOptions,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = crate::cmd::client::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
#[cfg(feature = "stats")]
let human_stats = stats_update.into();
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
Some(format_human_event_with_options(
event,
Some(human_stats),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
show_running_only_note: self.show_running_only_summary_note,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
#[cfg(feature = "stats")]
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
}
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let expected_probes = expected_probe_count(args.duration, args.interval);
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = format_bytes_for_warning(estimated_bytes);
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!(
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
))
}
#[cfg(feature = "stats")]
fn format_bytes_for_warning(bytes: u64) -> String {
if bytes >= GIB {
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
fn cli_args(args: &[&str]) -> ClientArgs {
let mut argv = vec!["irtt-rs"];
argv.extend_from_slice(args);
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_event(&reply_event(1, 1200)).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("irtt-rs summary"));
assert!(rendered.contains("medians unavailable"));
assert!(rendered.contains("continuous mode"));
assert!(rendered.contains("packets:"));
assert!(rendered.contains("received=1"));
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: SignedDuration::from_nanos(1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
for args in [
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
cli_args(&[
"--duration",
"1000ms",
"--interval",
"1ms",
"127.0.0.1:2112",
]),
] {
assert_eq!(finite_stats_memory_warning(&args), None);
}
for (threshold, size, guidance) in [
(
FINITE_STATS_MEMORY_WARNING_BYTES,
"about 128 MiB",
"bounded-memory long-running tests",
),
(
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
"about 512 MiB",
"shortening the run",
),
(
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
"about 1 GiB",
"memory-constrained systems",
),
] {
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
let args = cli_args(&[
"--duration",
&format!("{expected_probes}ms"),
"--interval",
"1ms",
"127.0.0.1:2112",
]);
let warning = finite_stats_memory_warning(&args).unwrap();
assert!(warning.contains(size));
assert!(warning.contains(guidance));
}
}
#[test]
fn expected_probe_count_rounds_up() {
assert_eq!(
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
2
);
}
#[test]
fn estimate_finite_stats_memory_bytes_saturates() {
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
}
}
-309
View File
@@ -1,309 +0,0 @@
use std::fmt::Write as _;
use irtt_stats::{Snapshot, TimeStats};
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
let mut out = String::new();
let packets = summary.packets;
let loss = summary.loss;
writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
)
.unwrap();
writeln!(out, " {}", "-".repeat(82)).unwrap();
write_time_row(&mut out, "RTT", &summary.rtt.primary);
if options.verbose {
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
}
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_time_row(
&mut out,
"receive delay",
&summary.one_way_delay.receive_delay,
);
write_time_row(
&mut out,
"server processing",
&summary.server_processing.processing,
);
write_time_row(&mut out, "send call", &summary.send_call);
write_time_row(&mut out, "timer error", &summary.timer_error);
writeln!(out).unwrap();
writeln!(
out,
"packets: sent={} received={} unique={} lost={} loss={}",
packets.packets_sent,
packets.packets_received,
packets.unique_replies,
loss.lost_packets,
format_percent(loss.packet_loss_percent)
)
.unwrap();
if packets.duplicates != 0 || packets.late_packets != 0 {
writeln!(
out,
"replies: duplicates={} ({}) late={} ({})",
packets.duplicates,
format_percent(loss.duplicate_percent),
packets.late_packets,
format_percent(loss.late_packets_percent)
)
.unwrap();
}
writeln!(
out,
"bytes: sent={} received={}",
packets.bytes_sent, packets.bytes_received
)
.unwrap();
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
write!(out, "server:").unwrap();
if let Some(count) = packets.server_packets_received {
write!(out, " received={count}").unwrap();
}
if let Some(window) = packets.server_received_window {
write!(out, " window={window:#x}").unwrap();
}
writeln!(out).unwrap();
}
out
}
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
if value.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.count,
format_ns_i128(value.min_ns),
format_ns_f64(value.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_i128(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
fn format_percent(value: f64) -> String {
format!("{value:.2}%")
}
fn format_ns_i128(value: Option<i128>) -> String {
value
.map(|value| format_ns_f64(value as f64))
.unwrap_or_else(|| "-".to_owned())
}
fn format_ns_f64_opt(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned())
}
fn format_ns_f64(value: f64) -> String {
let sign = if value.is_sign_negative() { "-" } else { "" };
let value = value.abs();
if value < 1_000.0 {
format!("{sign}{value:.0}ns")
} else if value < 1_000_000.0 {
format!("{sign}{:.1}µs", value / 1_000.0)
} else if value < 1_000_000_000.0 {
format!("{sign}{:.1}ms", value / 1_000_000.0)
} else {
format!("{sign}{:.3}s", value / 1_000_000_000.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
};
use irtt_stats::{StatsCollector, StatsConfig};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
#[test]
fn empty_summary_omits_optional_metric_sections() {
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
let output = format_summary(&summary);
assert!(output.contains("Metric"));
assert!(output.contains("Min"));
assert!(output.contains("Mean"));
assert!(output.contains("Median"));
assert!(output.contains("Max"));
assert!(output.contains("Stddev"));
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
assert!(!output.contains("RTT 0"));
assert!(!output.contains("server processing"));
}
#[test]
fn summary_formats_counts_and_available_metrics() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
receive_mono_ns: None,
send_wall_ns: None,
send_mono_ns: None,
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary(&collector.snapshot());
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
assert!(output.contains("bytes: sent=64 received=64"));
assert!(output.contains("RTT"));
assert!(output.contains("1.2ms"));
assert!(!output.contains("raw RTT"));
assert!(!output.contains("adjusted RTT"));
assert!(output.contains("server processing"));
assert!(output.contains("300.0µs"));
assert!(output.contains("send call"));
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
#[test]
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary_with_options(
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("raw RTT"));
assert!(output.contains("adjusted RTT"));
}
#[test]
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
median_ns: Some(-500.0),
variance_ns2: 0.0,
};
let mut out = String::new();
write_time_row(&mut out, "RTT", &stats);
assert!(out.contains("-500ns"));
}
}
-7
View File
@@ -1,7 +0,0 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "tui")]
pub mod tui;
// Future command applets, such as a server applet, belong here behind their
// own feature gate.
-134
View File
@@ -1,134 +0,0 @@
use std::time::Duration;
use clap::Parser;
use irtt_client::ClientConfig;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{parse_test_duration, CommonClientArgs};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(
long,
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."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::client::ClientArgs;
use clap::{CommandFactory, Parser};
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.to_client_config().duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.to_client_config().duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn shared_client_options_match_between_stream_and_tui_parsers() {
let shared = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let client = parse_client(&shared).unwrap().to_client_config();
let tui = parse(&shared).unwrap().to_client_config();
assert_eq!(client.server_addr, tui.server_addr);
assert_eq!(client.duration, tui.duration);
assert_eq!(client.interval, tui.interval);
assert_eq!(client.length, tui.length);
assert_eq!(client.received_stats, tui.received_stats);
assert_eq!(client.stamp_at, tui.stamp_at);
assert_eq!(client.clock, tui.clock);
assert_eq!(client.dscp, tui.dscp);
assert_eq!(client.hmac_key, tui.hmac_key);
assert_eq!(client.server_fill, tui.server_fill);
assert_eq!(client.negotiation_policy, tui.negotiation_policy);
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl);
}
}
-216
View File
@@ -1,216 +0,0 @@
use std::{
io,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_stats::StatsCollector;
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{is_shutdown_requested, ClientSession},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
if continuous {
irtt_stats::StatsConfig::continuous()
} else {
irtt_stats::StatsConfig::finite()
}
}
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::new(TuiConfig::from_args(&args));
let mut stats = StatsCollector::new(stats_config(continuous));
let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
Ok(session) => session,
Err(err) => {
state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
return Err(Box::new(err));
}
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
state.process_events(&events, &mut stats);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut interrupted = false;
// Keep this in lockstep with run_stream: send due probes, drain available
// replies, poll timeouts, sleep toward the next absolute send deadline,
// then perform the same final drain, timeout poll, and close sequence.
while session.should_continue(shutdown_requested) {
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
break;
}
let events = session.step(shutdown_requested)?;
state.process_events(&events, &mut stats);
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
wait_for_tui_activity(
session.next_send_deadline(),
&mut next_render,
&mut state,
&stats,
&mut terminal,
shutdown_requested,
)?;
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if session.should_drain_final(interrupted) {
state.set_status(TuiStatus::Draining);
let mut drain_render = Instant::now();
session.drain_final(|events| {
state.process_events(events, &mut stats);
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false);
})?;
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
let events = session.poll_timeouts()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let events = session.close()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
Ok(())
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
while event::poll(Duration::ZERO)? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.cycle_graph_mode();
force_render = true;
}
KeyCode::Char('f') => {
state.toggle_full_graph();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
stats: &StatsCollector,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if !should_render(now, *next_render, state.paused, force) {
return Ok(());
}
terminal.draw(state, &stats.snapshot())?;
*next_render = now + RENDER_INTERVAL;
Ok(())
}
fn wait_for_tui_activity(
next_send_deadline: Option<Instant>,
next_render: &mut Instant,
state: &mut TuiState,
stats: &StatsCollector,
terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool,
) -> io::Result<()> {
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
if wait_for.is_zero() || !event::poll(wait_for)? {
return Ok(());
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, stats, next_render, true)?;
}
Ok(())
}
fn tui_wait_duration(
next_send_deadline: Option<Instant>,
next_render: Instant,
paused: bool,
) -> Duration {
let now = Instant::now();
let send_wait = next_send_deadline
.map(|deadline| deadline.saturating_duration_since(now))
.unwrap_or(IDLE_SLEEP);
let render_wait = if paused {
send_wait
} else {
next_render.saturating_duration_since(now)
};
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
}
File diff suppressed because it is too large. Load diff
-92
View File
@@ -1,92 +0,0 @@
use std::{
env,
ffi::OsString,
process::ExitCode,
sync::{atomic::AtomicBool, Arc},
};
use irtt_cli::{
applet::{
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
},
signal::install_signal_handler,
};
fn main() -> ExitCode {
match run_from_env() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("irtt-rs: {err}");
ExitCode::FAILURE
}
}
}
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
let requested = match detect_applet_from_argv0(argv0) {
Some(applet) => applet,
None => default_requested_applet()?,
};
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => Err("server applet is not available in this build".into()),
}
}
fn default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
AppletAvailability::Client => Ok(RequestedApplet::Client),
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
AppletAvailability::Server => Ok(RequestedApplet::Server),
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
}
}
#[cfg(feature = "client")]
fn run_client_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
irtt_cli::cmd::client::run_stream(args, shutdown_requested)
}
#[cfg(not(feature = "client"))]
fn run_client_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[cfg(feature = "tui")]
fn run_tui_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
@@ -1,48 +0,0 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
impl CommonClientArgs {
pub fn to_client_config(&self, server: &str, duration: Duration) -> ClientConfig {
ClientConfig {
server_addr: server.to_owned(),
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
server_fill: self.server_fill.clone(),
negotiation_policy: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
socket_config: SocketConfig {
ttl: self.ttl,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
-10
View File
@@ -1,10 +0,0 @@
pub mod args;
pub mod config;
pub mod session;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
@@ -1,162 +0,0 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
pub struct ClientSession {
client: Client,
continuous: bool,
}
impl ClientSession {
pub fn connect(
config: ClientConfig,
continuous: bool,
) -> Result<Self, irtt_client::ClientError> {
Ok(Self {
client: Client::connect(config)?,
continuous,
})
}
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
Ok(vec![open_event(&self.client.open()?).clone()])
}
pub fn step(
&mut self,
shutdown_requested: &AtomicBool,
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
let mut events = Vec::new();
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
events.extend(self.client.send_probe()?);
}
events.extend(self.client.recv_available(RECV_BUDGET)?);
events.extend(self.client.poll_timeouts()?);
Ok(events)
}
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& (self.continuous || self.client.next_send_deadline().is_some())
}
pub fn should_drain_final(&self, interrupted: bool) -> bool {
should_drain_final(self.continuous, interrupted)
}
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
where
F: FnMut(&[ClientEvent]),
{
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
loop {
let mut received = false;
let events = self.client.recv_available(RECV_BUDGET)?;
received |= !events.is_empty();
on_events(&events);
let events = self.client.poll_timeouts()?;
received |= !events.is_empty();
on_events(&events);
if self.client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !received {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.poll_timeouts()
}
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.close()
}
pub fn next_send_deadline(&self) -> Option<Instant> {
self.client.next_send_deadline()
}
pub fn probe_timeout(&self) -> Duration {
self.client.probe_timeout()
}
pub fn sleep_until_next_send(&self) {
let sleep_for = match self.next_send_deadline() {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
#[test]
fn should_drain_final_for_finite_or_interrupted_runs() {
assert!(should_drain_final(false, false));
assert!(should_drain_final(false, true));
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
}
}
-2
View File
@@ -1,2 +0,0 @@
#[cfg(feature = "client")]
pub mod client;
-10
View File
@@ -1,10 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
+232
View File
@@ -0,0 +1,232 @@
# `irtt-client` guidance
Repository-wide guidance in the root `AGENTS.md` also applies here.
## Probe-send commitment API
`Client::send_probe` and `AsyncClient::send_probe` return
`Result<SendReceipt, SendProbeError>`; the Tokio poll driver returns the same
result inside `Poll`. A receipt represents an Echo datagram accepted by the
socket and already committed in session state: sequence and sent count advanced,
and the probe entered pending tracking. It contains `seq`, `remote`, the public
post-send `sent_at`, accepted `bytes`, and `send_call`, without schedule metadata.
`ClientEvent::from(receipt)` constructs `EchoSent` with no schedule annotations.
Classify every failure explicitly: `NotCommitted(ClientError)` means no datagram
was accepted and no probe committed; `AfterCommit { receipt, source }` preserves
the authoritative accepted send when length validation or TX error-queue
processing fails. There is no blanket `From<ClientError>` conversion. Do not
roll back a committed probe or classify a post-send error with an indiscriminate
`?`.
All fallible state/capacity/counter/allocation and timeout preparation precedes
submission. Immediately after `send`/`try_send` returns `Ok(bytes)`, capture the
public post-send timestamp, commit infallibly, and construct the receipt before
fallible post-send processing. No suspension, readiness wait, fallible allocation,
or fallible state operation may intervene between acceptance and commitment;
the Tokio poll must never return `Pending` after acceptance.
The managed driver consumes receipts for cadence/stagger acceptance and constructs
`EchoSent` with its scheduled timestamp and timer error. A post-commit failure
advances cadence and publishes that send before failure cleanup; the underlying
cause remains a sending failure. `NotCommitted` and `Pending` advance neither
cadence nor stagger gating. Counters are cumulative accounting, never evidence
of one operation's commitment.
Tokio retains one prepared probe across cancellation/readiness false positives
and `WouldBlock`, samples a fresh private anchor on each retry, and clears the
prepared probe after commitment even when post-send processing fails. Failed
submissions still invalidate kernel TX ID correlation as documented below.
## Linux kernel TX timestamp capture
On Linux, with the `ancillary` feature, the client best-effort upgrades its
socket from RX-only `SO_TIMESTAMPING` to RX+TX immediately after a successful
Open, before the first Echo probe can be sent:
```
socket created
-> RX timestamping configured (RX_SOFTWARE | SOFTWARE)
-> Open attempts/retries (untimestamped)
-> successful Open
-> best-effort enable TX timestamping (+ TX_SOFTWARE | OPT_ID | OPT_TSONLY)
-> first Echo probe
```
`TX_SOFTWARE` asks the kernel for a software send timestamp; `OPT_ID` asks it
to tag each notification with an automatically incrementing per-socket ID;
`OPT_TSONLY` means the notification never needs to carry the original
payload, so a zero-length receive buffer is enough. The upgrade is entirely
best-effort: if it fails, the client keeps operating on the original RX-only
configuration (`try_enable_tx_timestamping` restores it explicitly) and
timing falls back to userspace `sent_at` as it always has. TX capability
failure is never a public error and is never logged from the library.
### Best-effort kernel-ID correlation
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
correlation ID — no separate correlation map. On the normal successful-send
path, both counters start at zero after Open and advance together. This is
not a permanent identity: a kernel ID can theoretically be consumed while a
send is being prepared and then survive a subsequent send failure, leaving
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
assumption that failed or nonblocking sends cannot cause that desynchrony.
The effect is confined to the optional timing enhancement. A kernel timestamp
whose ID cannot be matched to a pending or retained timed-out probe is
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
userspace fallback. **After every failed probe submission, disable kernel-ID
correlation for the rest of that session.** A gap means an otherwise plausible
later ID can name the wrong probe, so subsequent error-queue timestamps must
be discarded rather than attached by ID. This is safe because:
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
successful Open (`SessionMachine::commit_open` builds a fresh
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
`commit_probe_sent`, after a confirmed successful socket send. TX
timestamping is enabled after that same successful Open and before any probe
is sent, so the normal zero points line up.
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
second session on the same socket, so there is no second epoch of wire
sequence numbers to collide with a stale kernel ID.
- **The one extra send (Close) is harmless.** `Close` sends one datagram
after TX timestamping is already enabled, generating one TX timestamp
record with no corresponding `wire_seq` (`Close` does not advance
`next_wire_seq`). Because the session is terminal immediately after,
nothing will ever look up that ID; `record_kernel_tx_timestamp`'s
"unknown ID -> ignore" rule discards it safely.
## MSG_ERRQUEUE drains
`receive::linux::error_queue` decodes one `MSG_ERRQUEUE` record at a time
(`try_recv_error_queue_record`) and classifies it: a genuine send-timestamp
completion (`TxTimestamp`), a timestamp-origin record that is not a usable
`SND` completion (`MalformedOrUnsupportedTimestamp` — never treated as
fatal, since this client only ever requests `TX_SOFTWARE`/`SND`, so anything
else on that origin is unexpected metadata, not a network error), a genuine
non-timestamp socket/network error (`SocketError`), or nothing (`Ignored`).
Origin (`SO_EE_ORIGIN_TIMESTAMPING` or not) is the only thing that decides
between the last two categories.
`receive::linux::drain_tx_timestamps` reads up to
`MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN` (32) records per call: nonblocking, one
record at a time, no heap allocation, stopping early once the queue is
empty. It is a starvation guard, not a queue-capacity promise — one TX
timestamp is generated per successful probe, and drains happen at several
natural choke points, so a modest constant comfortably covers realistic
backlog. `Client`/`AsyncClient` call it after a successful probe send,
before processing an inbound reply, and before evaluating timeouts; it never
waits for a timestamp; an absent timestamp after all of those opportunities
simply stays absent. A genuine `SocketError` record stops the drain and is
surfaced as `ClientError::Socket`. During post-send processing it is wrapped in
`SendProbeError::AfterCommit` with the accepted send's receipt; receive/timeout
operations retain the ordinary `ClientError` path.
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
is not ordinary read readiness, and driving it through the Tokio reactor
risks clearing readiness state that the normal Echo receive path depends on.
There is no background task or second `AsyncFd` around the Tokio-owned
socket.
### Testing kernel timestamp behavior
The requirements above describe production behavior, not a required unit-test
architecture.
Prefer Linux tests that exercise the real socket and kernel timestamp path
through production APIs. A test intended to prove TX timestamp support must
actually require and observe a kernel TX timestamp when running on a supported
Linux environment; accepting the userspace fallback does not prove that path.
Likewise, prefer observable socket/client behavior for genuine error-queue
errors and bounded draining where it can reasonably be induced.
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
decoding and ABI safety; `irtt-client` tests should primarily prove its own
observable interpretation and behavior through the real path.
If some invariant cannot reasonably be induced through a production interface,
a small focused internal test may be retained, but the burden is on that test to
justify why integration coverage is insufficient.
## Retained state
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
alongside the userspace `sent_at`. It survives pending -> timed-out retention
(`SessionMachine::record_kernel_tx_timestamp` updates whichever of
`PendingMap`/`TimedOutMap` still holds the probe) and is otherwise bounded by
the same limits those maps already enforce — no new unbounded state. First
valid observation wins on duplicates.
### Pre-send anchor vs. post-send `sent_at` (F-04)
The probe send transaction splits into two timestamps around the socket
send, not one:
- A **private pre-send anchor**, sampled immediately before the fallible
commit preflight (`SessionMachine::finalize_probe_commit`) that must
happen before the socket send: timeout deadline `checked_add` arithmetic
(so `DurationOverflow` still guarantees nothing was transmitted) and the
kernel TX lower-plausibility bound. Its monotonic half becomes
`PendingProbe::timeout_at`'s basis; its wall half becomes
`PendingProbe::tx_not_before_wall`. It is never exposed publicly.
- The **public `sent_at`**, a paired wall/monotonic `ClientTimestamp`
captured immediately after the socket send returns successfully
(`SessionMachine::commit_probe_sent`, which is infallible — all fallible
work already happened against the pre-send anchor). This is the RTT
endpoint, the userspace upstream-OWD fallback endpoint, `timer_error`'s
basis, and the value surfaced on `EchoSent`/`EchoReply`/`EchoLoss`/
`LateReply`.
So `timeout_at` is deliberately **not** derived from `sent_at.mono` — it
remains anchored to before the send, on purpose, so the existing pre-send
timeout/overflow/no-send contract is unchanged. Only the *measurement*
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
samples a `sent_at`, never calls `finalize_probe_commit`'s matching
`commit_probe_sent`, and never advances the machine; the managed runner advances cadence only
after an accepted send; each retry
gets its own fresh pre-send anchor, and only a successful attempt's anchor
and `sent_at` are ever retained.
**The kernel TX timestamp is eligible for exactly one measurement: the
upstream (`client_to_server`) one-way delay send endpoint.**
`compute_one_way`'s private `preferred_send_wall` helper prefers it over the
userspace `sent_at.wall` sample when
tx_not_before_wall <= kernel_tx_timestamp <= reply_received_at.wall
(inclusive, compared only against the client's own wall clock — never
against the remote server's wall timestamp). The lower bound is the
pre-send `tx_not_before_wall`, not the post-send `sent_at.wall`: a
legitimate `TX_SOFTWARE` timestamp can be generated while the send syscall
is still in flight, so it can legitimately precede the post-send `sent_at`
sample without being implausible. Otherwise, including when no kernel
timestamp was observed, the upstream endpoint falls back to `sent_at.wall`
(the post-send sample); a rejected observation is never erased from
`PendingProbe`, only skipped for that measurement. There is no
maximum-lag bound: unlike the kernel receive side, legitimate client
send-to-reply time can exceed a second.
`sent_at` remains the RTT endpoint and the userspace fallback for the
upstream (`client_to_server`) one-way delay, exactly as before this
capability existed. It is not an input to the downstream
(`server_to_client`) one-way delay, which is derived solely from
`ReceiveMeta::preferred_receive_wall` and the negotiated server send
timestamp — unaffected by this capability or by F-04. `TX_SOFTWARE` is
still a software kernel timestamp near the driver handoff, not a NIC
hardware transmit time. `irtt-stats`'s `ReplySample`
copies public `sent_at` straight into `client_send_mono`/`client_send_wall_ns`,
which `send_ipdv_ns` compares across probes for the upstream IPDV component —
so that component shifts endpoint along with `sent_at` itself (pre-send vs.
post-send) exactly as RTT and the upstream OWD fallback do; it is not a
separate invariant. A normal
reply and a measurable late reply (one whose `PendingProbe` is still
retained in `TimedOutMap`) apply the identical selection policy; an
untracked late reply has no retained probe and stays unmeasurable, and a
kernel timestamp that arrives after a reply has already been processed is
simply never observed by that reply.
+28 -6
View File
@@ -1,25 +1,47 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.3.0" version = "0.6.0"
description = "IRTT-compatible Rust client library" description = "IRTT-compatible Rust client library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true homepage.workspace = true
readme.workspace = true readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"] keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["network-programming"] categories = ["network-programming"]
[dependencies] [dependencies]
irtt-proto.workspace = true irtt-proto.workspace = true
libc = { version = "0.2.186", optional = true } socket2 = { version = "0.6.5", features = ["all"] }
socket2 = { version = "0.6.4", features = ["all"] } thiserror = "2.0.21"
thiserror = "2.0.18" tokio = { version = "1.53.2", 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 }
[features] [features]
default = [] default = []
ancillary = ["dep:libc"] ancillary = ["dep:nix"]
tokio = ["dep:tokio"]
[dev-dependencies]
irtt-server.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
[[example]]
name = "blocking_client"
doc-scrape-examples = true
[[example]]
name = "tokio_client"
required-features = ["tokio"]
doc-scrape-examples = true
[[example]]
name = "managed_client"
required-features = ["tokio"]
doc-scrape-examples = true
+109
View File
@@ -0,0 +1,109 @@
# irtt-client
Reusable client/session library for IRTT-compatible round-trip-time probing:
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
classification, and a typed event stream.
## Looking for the CLI?
If you just want to run probes from the command line, use the
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
which installs the `irtt-client` binary. This crate is the library the
binaries are built on.
## API tiers
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
default build.
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
own a runtime and drive readiness directly.
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
own dedicated current-thread runtime.
`Client` and `AsyncClient` send one probe whenever called. They expose the
negotiated parameters, pending-probe status, and timeout deadlines; callers
own cadence and run duration. The managed driver provides fixed cadence,
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
sends return `Result<SendReceipt, SendProbeError>` instead of event lists.
A `SendReceipt` carries the committed sequence, remote, post-send timestamp,
accepted bytes, and send-call duration. `NotCommitted(ClientError)` guarantees
no accepted datagram or committed probe; `AfterCommit { receipt, source }`
preserves the accepted send when subsequent validation or TX processing fails.
Use `ClientEvent::from(receipt)` for an `EchoSent` event with `scheduled_at` and
`timer_error` absent; the managed driver supplies those schedule measurements
and publishes committed sends even when post-send processing fails.
Tokio stays optional: the default build has no runtime and no Tokio
dependency at all.
See `examples/` in the repository for runnable examples of each tier.
## Reusable configuration
`ClientConfig` contains no endpoint. The low-level APIs take it separately:
`Client::connect(endpoint, config)` and
`AsyncClient::connect(endpoint, config).await`, accepting `String` or `&str`
endpoints. An omitted port defaults to 2112.
Configuration is grouped by ownership:
- `address_family`: `AddressFamily::{Any, Ipv4, Ipv6}`, defaulting to `Any`.
It filters remote resolution; `Ipv6` also sets `IPV6_V6ONLY`.
- `socket`: `SocketConfig` for local bind, device/routing options, and TTL.
- `request`: `SessionRequest` for duration, interval, length, received stats,
timestamps, clock, DSCP, server fill, and run mode.
- `open`: `OpenPolicy` for attempt `timeouts` and `negotiation` policy.
- `auth`, `probe_timeout`, and `max_pending_probes`: authentication and local
probe tracking policy.
Clone one config to connect to multiple endpoints. Managed targets retain their
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.
Open attempts use only `open.timeouts`, restoring the adapter setting afterward.
Async and managed clients have no blocking receive-timeout setting.
This breaks the former flat config, endpoint-in-config, socket family flags,
and `SocketConfig.recv_timeout` APIs. Use the grouped fields and adapter method
directly; defaults and protocol/runtime behavior are preserved.
## Authentication
`ClientConfig.auth` is concrete: `Authentication::Unauthenticated` or
`Authentication::Hmac(HmacKey::new(bytes))`. Keys share their bytes across
clones and print only `HmacKey([REDACTED])` through `Debug`. Borrowing key
material requires an explicit `as_bytes()` call. An empty key still selects
HMAC authentication; textual key syntax belongs to the application.
For managed targets, `ManagedTargetConfig.auth` defaults to `TargetAuth::Inherit`.
`TargetAuth::Override(Authentication::Unauthenticated)` disables shared
authentication, and `TargetAuth::Override(Authentication::Hmac(key))` replaces
the shared key. Initial construction and update planning resolve inheritance
into concrete session authentication before execution. Switching between
inheritance and an override replaces the target generation even when their
resolved authentication is equal.
This replaces the former `ClientConfig.hmac_key` and `ClientAuthConfig` API;
there are no compatibility aliases.
## Documentation
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,134 @@
//! Runtime-free blocking client example.
//!
//! Opens a session against a local IRTT-compatible server, sends a few
//! probes, and prints the events observed. Uses only [`Client`], which has no
//! Tokio dependency and works in a default (`--no-default-features`) build.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example blocking_client
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open.timeouts` for a
//! fast demonstration; a real caller would normally keep the default retry
//! schedule.
use std::time::{Duration, Instant};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest};
const RUN_DURATION: Duration = Duration::from_secs(2);
fn main() {
let config = ClientConfig {
open: OpenPolicy {
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
timeouts: vec![Duration::from_millis(300)],
..Default::default()
},
request: SessionRequest {
duration: Some(RUN_DURATION),
interval: Duration::from_millis(200),
..Default::default()
},
..Default::default()
};
let mut client = match Client::connect("127.0.0.1:2112", config) {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
if let Err(err) = client.set_recv_timeout(Some(Duration::from_millis(100))) {
eprintln!("failed to configure receive timeout: {err}");
return;
}
let negotiated = match client.open() {
Ok(OpenOutcome::Started(started)) => {
println!("session opened: {started:?}");
started.negotiation
}
Ok(OpenOutcome::NoTestCompleted(completed)) => {
println!("{completed:?}");
return;
}
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
};
// This loop owns cadence and duration. The transport sends whenever called.
let interval = negotiated.accepted.interval;
let start = Instant::now();
let end = start
+ negotiated
.accepted
.duration
.expect("example requests a finite run");
let deadline = end + client.probe_timeout();
let mut next_send = start;
while Instant::now() < deadline && !client.is_peer_closed() {
let now = Instant::now();
if now >= end && !client.has_pending_probes() {
break;
}
if now < end && now >= next_send {
match client.send_probe() {
Ok(receipt) => {
print_event(&receipt.into());
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => {
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
print_event(&(*receipt).into());
}
eprintln!("send failed: {err}");
break;
}
}
}
match client.recv_once() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
if client.is_peer_closed() {
return;
}
match client.close() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
@@ -0,0 +1,79 @@
//! Managed multi-target client example (`tokio` feature).
//!
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
//! that does not want to touch Tokio directly can still get managed
//! multi-target orchestration, retries, and a lossy event stream.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example managed_client --features tokio
//! ```
//!
//! This example does not wait for the managed run to finish naturally: it
//! requests a stop after a fixed short window so it exits promptly even
//! without a reachable server.
use std::time::Duration;
use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
ManagedTargetConfig,
};
use irtt_client::{ClientConfig, OpenPolicy, SessionRequest};
fn main() {
let config = ManagedClientConfig {
client: ClientConfig {
open: OpenPolicy {
timeouts: vec![Duration::from_millis(300)],
..Default::default()
},
request: SessionRequest {
duration: None,
interval: Duration::from_millis(200),
..Default::default()
},
..Default::default()
},
completion: ManagedCompletionPolicy::ExplicitStop,
..Default::default()
};
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
{
Ok(started) => started,
Err(err) => {
eprintln!("failed to start managed client: {err}");
return;
}
};
let stop_after = Duration::from_secs(1);
let deadline = std::time::Instant::now() + stop_after;
while std::time::Instant::now() < deadline {
match events.try_recv() {
Ok(event) => print_event(&event),
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
// `stop()` requests the stop synchronously; the returned receipt is only
// useful for awaiting durable acknowledgement, which this blocking
// example does not need.
std::mem::drop(owner.handle().stop());
match owner.join() {
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
}
}
fn print_event(event: &ManagedEvent) {
match event {
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
other => println!("{other:?}"),
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Low-level Tokio client example (`tokio` feature).
//!
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
//! adapter to reach for when you already have a Tokio runtime and want to
//! await sends/receives yourself, rather than use the higher-level `managed`
//! driver (see the `managed_client` example).
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example tokio_client --features tokio
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open.timeouts` for a
//! fast demonstration.
use std::time::{Duration, Instant};
use irtt_client::{
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest,
};
/// Upper bound on how long the receive wait can go without also calling
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
/// probe's own timeout deadline, so this example polls on a short fixed
/// cadence instead of waiting for the full `probe_timeout()` from whenever
/// this loop last ran — that would let poll_timeouts() classify a lost
/// probe (and its dependent statistics/session state) far later than it
/// actually became lost.
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.expect("failed to build Tokio runtime");
runtime.block_on(run());
}
async fn run() {
let config = ClientConfig {
open: OpenPolicy {
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
timeouts: vec![Duration::from_millis(300)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_secs(2)),
interval: Duration::from_millis(200),
..Default::default()
},
..Default::default()
};
let mut client = match AsyncClient::connect("127.0.0.1:2112", config).await {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
let negotiated = match client.open().await {
Ok(OpenOutcome::Started(started)) => {
println!("session opened: {started:?}");
started.negotiation
}
Ok(OpenOutcome::NoTestCompleted(completed)) => {
println!("{completed:?}");
return;
}
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
};
// This loop owns cadence and duration. The transport sends whenever called.
let interval = negotiated.accepted.interval;
let start = Instant::now();
let end = start
+ negotiated
.accepted
.duration
.expect("example requests a finite run");
let deadline = end + client.probe_timeout();
let mut next_send = start;
while Instant::now() < deadline && !client.is_peer_closed() {
if Instant::now() >= end && !client.has_pending_probes() {
break;
}
// Bound the receive wait by the next send/timeout deadline, rather
// than awaiting recv() unconditionally: a lost or rate-limited reply
// must not stall pacing or timeout classification indefinitely.
let wake_at = (next_send < end)
.then_some(next_send)
.into_iter()
.chain(client.next_probe_timeout_deadline())
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
.min()
.unwrap();
let recv_result = tokio::select! {
events = client.recv() => Some(events),
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
};
if let Some(result) = recv_result {
match result {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
}
if Instant::now() < end && Instant::now() >= next_send {
match client.send_probe().await {
Ok(receipt) => {
print_event(&receipt.into());
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => {
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
print_event(&(*receipt).into());
}
eprintln!("send failed: {err}");
break;
}
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
if client.is_peer_closed() {
return;
}
match client.close().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
+902
View File
@@ -0,0 +1,902 @@
use std::{
future::{poll_fn, Future},
io,
net::SocketAddr,
pin::Pin,
task::{Context, Poll},
time::{Duration, Instant},
};
use tokio::time;
use crate::{
client::validate_datagram_length,
config::ClientConfig,
error::ClientError,
event::{ClientEvent, OpenOutcome},
receive::{drain_tx_timestamps, try_enable_tx_timestamping, try_recv_tokio_datagram},
session::machine::{
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
},
socket::{connect_tokio_udp_socket, resolve_remote_tokio},
socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
timing::ClientTimestamp,
SendProbeError, SendReceipt,
};
/// Maximum amount of immediately available opening work performed by one poll.
///
/// Opening may consume ignored UDP datagrams, roll over an expired attempt, or
/// encounter readiness false positives without ever awaiting. Bound that work
/// so one managed target cannot retain the current-thread runtime.
const OPEN_POLL_WORK_BUDGET: usize = 64;
/// Maximum immediately available receive work performed by one
/// [`AsyncClient::poll_recv`] poll.
///
/// A `WouldBlock` readiness false positive is naturally bounded: `try_io`
/// clears the reactor's readiness on `WouldBlock`, so the next
/// `poll_recv_ready` genuinely awaits a fresh edge. An `Interrupted`
/// (`EINTR`) nonblocking `recvmsg` does not clear that readiness — the
/// syscall was interrupted, not told there was nothing to read — so without
/// an explicit bound a sustained signal delivery rate could otherwise retry
/// in a tight loop on this executor thread. Bounding both cases the same way
/// this crate already bounds open-attempt work keeps that impossible.
const RECV_POLL_WORK_BUDGET: usize = 64;
#[derive(Debug)]
struct PreparedAsyncOpen {
machine: PreparedOpenAcceptance,
recv_buffer_len: Option<usize>,
negotiated_traffic_class: Option<u8>,
}
#[derive(Debug)]
struct PreparedAsyncOpenFailure {
primary: ClientError,
machine: PreparedOpenAcceptance,
}
#[derive(Debug)]
enum AsyncOpenCleanup {
Acceptance {
primary: ClientError,
packet: Option<Box<[u8]>>,
},
Adapter(Box<PreparedAsyncOpenFailure>),
}
impl AsyncOpenCleanup {
fn packet(&self) -> Option<&[u8]> {
match self {
Self::Acceptance { packet, .. } => packet.as_deref(),
Self::Adapter(failure) => failure.machine.cleanup_close_packet(),
}
}
fn into_primary(self) -> ClientError {
match self {
Self::Acceptance { primary, .. } => primary,
Self::Adapter(failure) => failure.primary,
}
}
}
/// Owned state for one asynchronous open transaction.
///
/// This state deliberately contains no reference to [`AsyncClient`], allowing
/// a managed target to store the client and opening state side by side.
#[derive(Debug)]
pub(crate) struct AsyncOpenState {
attempt: usize,
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>,
}
impl AsyncOpenState {
pub(crate) fn new() -> Self {
Self {
attempt: 0,
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,
}
}
fn start_attempt(&mut self, timeout: Duration) -> Result<(), ClientError> {
let deadline = Instant::now()
.checked_add(timeout)
.ok_or(ClientError::DurationOverflow)?;
self.deadline = Some(deadline);
self.deadline_timer = Some(Box::pin(time::sleep_until(deadline.into())));
self.request_submitted = false;
Ok(())
}
fn finish_attempt(&mut self) {
self.attempt += 1;
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")
}
fn deadline_timer(&mut self) -> &mut Pin<Box<time::Sleep>> {
self.deadline_timer
.as_mut()
.expect("an open attempt has an active deadline timer")
}
}
/// Low-level Tokio IRTT client for one connected UDP target.
///
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
/// methods are polled by the caller and require a current Tokio runtime with I/O
/// and time enabled.
///
/// # Example
///
/// Use this low-level adapter when the application already owns Tokio and wants
/// to drive the session lifecycle itself. This example type-checks without
/// contacting a server.
///
/// ```no_run
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect("127.0.0.1:2112", ClientConfig::default()).await?;
/// let outcome = client.open().await?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe().await?;
/// println!("sent: {sent:?}");
/// let received = client.recv().await?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close().await?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct AsyncClient {
socket: tokio::net::UdpSocket,
machine: SessionMachine,
remote: SocketAddr,
recv_buffer: Vec<u8>,
applied_traffic_class: Option<u8>,
prepared_open: Option<PreparedOpenRequest>,
prepared_probe: Option<PreparedProbe>,
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
/// invariant this tracks; it is the same adapter-owned capability flag.
tx_timestamping_enabled: bool,
}
impl AsyncClient {
/// Resolve the endpoint and construct one connected Tokio UDP
/// socket.
///
/// Polling this future without a current Tokio runtime returns
/// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
/// drivers is outside this type's runtime contract.
///
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
/// IPv6 literals may be bracketed or unbracketed with the default port.
/// The reusable config contains no endpoint identity.
pub async fn connect(
endpoint: impl AsRef<str>,
config: ClientConfig,
) -> Result<Self, ClientError> {
tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
config.open.validate()?;
let remote = resolve_remote_tokio(endpoint.as_ref(), config.address_family).await?;
let machine = SessionMachine::new(config.clone(), remote)?;
let prepared_open = machine.prepare_open_request()?;
let socket = connect_tokio_udp_socket(&config.socket, remote, config.address_family)?;
Ok(Self {
socket,
machine,
remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
applied_traffic_class: None,
prepared_open: Some(prepared_open),
prepared_probe: None,
tx_timestamping_enabled: false,
})
}
/// Perform the IRTT open exchange.
///
/// Each configured attempt uses one absolute deadline for one successful
/// request submission and all replies inspected during that attempt.
/// Malformed, unrelated, wrong-direction, and unauthenticated datagrams are
/// ignored without resending the request.
///
/// Dropping this future before trusted acceptance leaves the local machine
/// connected with no negotiated adapter state. If it is dropped during
/// best-effort post-token cleanup, cleanup may remain incomplete because
/// this low-level client never detaches cleanup work.
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let mut state = AsyncOpenState::new();
poll_fn(|cx| self.poll_open(&mut state, cx)).await
}
/// Send one echo probe now, committing protocol state only after acceptance.
/// The caller owns pacing and run duration.
///
/// A prepared packet is retained across readiness false positives,
/// cancellation, and socket errors so a later call retries the same logical
/// probe without advancing state before kernel acceptance. Cancellation or
/// `WouldBlock` before submission is uncommitted. After acceptance there is
/// no suspension before infallible commitment, and the prepared probe is
/// cleared even if post-send processing returns [`SendProbeError::AfterCommit`].
/// That failure retains the authoritative receipt; earlier failures return
/// [`SendProbeError::NotCommitted`].
pub async fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
poll_fn(|cx| self.poll_send_probe(cx)).await
}
/// Await and classify one complete UDP datagram.
///
/// State is validated before readiness is awaited. Readiness false positives
/// retry without changing protocol state. Authenticated peer-close events
/// remain authoritative even if best-effort DSCP cleanup fails.
pub async fn recv(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
poll_fn(|cx| self.poll_recv(cx)).await
}
/// Poll protocol timeouts using a newly captured monotonic timestamp.
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.poll_timeouts_at(Instant::now())
}
/// Poll protocol timeouts using the caller's monotonic timestamp.
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
self.drain_tx_timestamps()?;
self.machine.poll_timeouts_at(now)
}
pub(crate) fn poll_timeouts_bounded_at(
&mut self,
now: Instant,
limit: usize,
) -> Result<TimeoutBatch, ClientError> {
self.drain_tx_timestamps()?;
self.machine.poll_timeouts_bounded_at(now, limit)
}
/// Send the retained close packet and commit local close exactly once.
///
/// DSCP is cleared only after write readiness and immediately before the
/// nonblocking send attempt. A `WouldBlock` result restores it before the
/// next await, so dropping this future at a suspension point cannot leave
/// an otherwise-open session with cleared DSCP.
pub async fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
poll_fn(|cx| self.poll_close(cx)).await
}
pub(crate) fn poll_open(
&mut self,
state: &mut AsyncOpenState,
cx: &mut Context<'_>,
) -> Poll<Result<OpenOutcome, ClientError>> {
if self.prepared_open.is_none() {
if let Err(error) = self.machine.prepare_open_request() {
return Poll::Ready(Err(error));
}
unreachable!("connected async clients retain an open request");
}
for _ in 0..OPEN_POLL_WORK_BUDGET {
if state.cleanup.is_some() {
return self.poll_open_cleanup(state, cx);
}
let attempt_count = self.machine.config().open.timeouts.len();
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) {
return Poll::Ready(Err(error));
}
}
if !state.request_submitted {
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Ready(Ok(false)) => {
state.finish_attempt();
continue;
}
Poll::Ready(Ok(true)) => {}
}
let request = self
.prepared_open
.as_ref()
.expect("connected clients retain their prepared open request");
let send_result = self.socket.try_send(&request.bytes);
match send_result {
Ok(bytes) => {
state.request_submitted = true;
if let Err(error) = validate_datagram_length(request.bytes.len(), bytes) {
return Poll::Ready(Err(error));
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
}
}
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending,
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)) => {}
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
Ok(datagram) => datagram,
// WouldBlock is a readiness false positive; Interrupted
// (EINTR) is a transient nonblocking-recvmsg interruption.
// Both retry through the surrounding `OPEN_POLL_WORK_BUDGET`-
// bounded loop above, which re-checks readiness before the
// next attempt and yields back to the executor once that
// budget is exhausted.
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) =>
{
continue
}
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
};
if datagram.received_at.mono > state.deadline() {
state.finish_attempt();
if state.stopped_after_current_attempt() {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
continue;
}
let reply = match self
.machine
.inspect_open_datagram(&state.buffer[..datagram.len])
{
Ok(OpenDatagramDisposition::Ignore) => continue,
Ok(OpenDatagramDisposition::Trusted(reply)) => reply,
Err(error) => return Poll::Ready(Err(error)),
};
let machine = match self
.machine
.prepare_open_acceptance(reply, datagram.received_at)
{
Ok(machine) => machine,
Err(failure) => {
state.cleanup = Some(AsyncOpenCleanup::Acceptance {
primary: failure.primary,
packet: failure.cleanup_close,
});
continue;
}
};
let prepared = match self.prepare_async_open(machine) {
Ok(prepared) => prepared,
Err(failure) => {
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
continue;
}
};
return Poll::Ready(Ok(self.commit_async_open(prepared)));
}
// We deliberately truncated known immediate opening work. Arrange for
// another poll even if no new socket-readiness edge occurs.
cx.waker().wake_by_ref();
Poll::Pending
}
pub(crate) fn poll_send_probe(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<SendReceipt, SendProbeError>> {
if let Err(error) = self.machine.ensure_open() {
return Poll::Ready(Err(SendProbeError::NotCommitted(error)));
}
if self.prepared_probe.is_none() {
match self.machine.prepare_probe() {
Ok(prepared) => self.prepared_probe = Some(prepared),
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
}
}
loop {
match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
Poll::Ready(Err(error)) => {
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
error,
))));
}
}
let prepared = self
.prepared_probe
.as_ref()
.expect("prepared probe was retained across readiness");
let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
Ok(preflight) => preflight,
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
};
let expected_bytes = prepared.bytes.len();
// Private pre-send anchor: finalizes all fallible commit work
// (timeout deadline arithmetic, kernel TX lower bound) before
// the send attempt. A WouldBlock retry samples a fresh one on
// the next loop iteration; only the successful attempt's anchor
// is retained (via `machine_commit`/`commit_probe_sent` below).
let send_anchor = ClientTimestamp::now();
let machine_commit = match self
.machine
.finalize_probe_commit(machine_preflight, send_anchor)
{
Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
};
let send_call_start = Instant::now();
let send_result = self.socket.try_send(&prepared.bytes);
let bytes = match send_result {
Ok(bytes) => bytes,
// No measurement or machine commit: retry the transaction
// with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
self.machine.invalidate_kernel_tx_correlation();
continue;
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
error,
))));
}
};
let send_finished_at = Instant::now();
// Public post-send measurement, captured only after the
// successful send above. The subsequent machine commit
// is infallible.
let sent_at = ClientTimestamp::now();
let sent = self
.machine
.commit_probe_sent(machine_commit, sent_at, bytes);
self.prepared_probe = None;
let send_call = send_finished_at.saturating_duration_since(send_call_start);
let receipt = SendReceipt {
seq: sent.seq,
remote: self.remote,
sent_at: sent.sent_at,
bytes: sent.bytes,
send_call,
};
if let Err(source) = validate_datagram_length(expected_bytes, bytes) {
return Poll::Ready(Err(SendProbeError::AfterCommit {
receipt,
source: Box::new(source),
}));
}
if let Err(source) = self.drain_tx_timestamps() {
return Poll::Ready(Err(SendProbeError::AfterCommit {
receipt,
source: Box::new(source),
}));
}
return Poll::Ready(Ok(receipt));
}
}
pub(crate) fn poll_recv(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
if let Err(error) = self.machine.ensure_open() {
return Poll::Ready(Err(error));
}
for _ in 0..RECV_POLL_WORK_BUDGET {
match self.socket.poll_recv_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
Poll::Ready(Err(error)) => {
return Poll::Ready(Err(ClientError::Socket(error)));
}
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => datagram,
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
// positive and an interrupted nonblocking recvmsg retry
// within this bounded loop rather than failing the receive.
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) =>
{
continue
}
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
};
// One final bounded drain before processing the reply, so a TX
// timestamp that raced the reply is still associated with its
// probe before that probe is looked up and possibly removed
// below.
if let Err(error) = self.drain_tx_timestamps() {
return Poll::Ready(Err(error));
}
let events = match self.machine.process_received_echo_packet(
&self.recv_buffer[..datagram.len],
datagram.received_at,
datagram.meta,
) {
Ok(events) => events,
Err(error) => return Poll::Ready(Err(error)),
};
if self.machine.is_peer_closed() {
self.prepared_probe = None;
if clear_dscp_on_tokio_socket(&self.socket, self.remote).is_ok() {
self.applied_traffic_class = None;
}
}
return Poll::Ready(Ok(events));
}
// Truncated known immediate receive work, exactly as `poll_open`
// does: arrange for another poll even if no new socket-readiness
// edge occurs, rather than spinning this executor thread further.
cx.waker().wake_by_ref();
Poll::Pending
}
pub(crate) fn poll_close(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
loop {
let prepared = match self.machine.prepare_close() {
Ok(prepared) => prepared,
Err(error) => return Poll::Ready(Err(error)),
};
let mut events = Vec::new();
if let Err(source) = events.try_reserve(1) {
return Poll::Ready(Err(ClientError::AllocationFailed {
operation: "close event result",
source,
}));
}
let previous_traffic_class = self.applied_traffic_class;
let expected_bytes = prepared.bytes.len();
match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
Poll::Ready(Err(error)) => {
return Poll::Ready(Err(ClientError::Socket(error)));
}
}
if let Err(error) = clear_dscp_on_tokio_socket(&self.socket, self.remote) {
return Poll::Ready(Err(error));
}
let mut rollback =
DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
let send_result = self.socket.try_send(prepared.bytes);
let bytes = match send_result {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if let Err(error) = rollback.restore() {
return Poll::Ready(Err(error));
}
continue;
}
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
};
let close_sent_at = ClientTimestamp::now();
let event = self
.machine
.commit_local_close(prepared.commit, close_sent_at);
rollback.disarm();
self.prepared_probe = None;
self.applied_traffic_class = None;
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
return Poll::Ready(Err(error));
}
events.push(event);
return Poll::Ready(Ok(events));
}
}
/// Return the configured local probe timeout.
pub fn probe_timeout(&self) -> Duration {
self.machine.probe_timeout()
}
/// Accepted semantics and exact peer parameters of the currently live session.
///
/// Returns `None` before Open, after Close, and after no-test completion.
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
self.machine.negotiation()
}
/// Whether any sent probes still await a reply or timeout.
pub fn has_pending_probes(&self) -> bool {
!self.machine.pending_is_empty()
}
/// Return whether an authenticated peer close ended the session.
pub fn is_peer_closed(&self) -> bool {
self.machine.is_peer_closed()
}
pub(crate) fn remote_addr(&self) -> SocketAddr {
self.remote
}
pub(crate) fn packets_sent(&self) -> u64 {
self.machine.packets_sent()
}
/// Earliest timeout deadline among probes still awaiting a reply.
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.next_probe_timeout_deadline()
}
pub(crate) fn latest_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.latest_probe_timeout_deadline()
}
pub(crate) fn discard_prepared_probe(&mut self) {
self.prepared_probe = None;
}
fn prepare_async_open(
&mut self,
machine: PreparedOpenAcceptance,
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
let Some(negotiated) = machine.normal_negotiated() else {
return Ok(PreparedAsyncOpen {
machine,
recv_buffer_len: None,
negotiated_traffic_class: None,
});
};
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
Ok(size) => size,
Err(primary) => {
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
}
};
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
if let Err(source) = self.recv_buffer.try_reserve(additional) {
return Err(Box::new(PreparedAsyncOpenFailure {
primary: ClientError::AllocationFailed {
operation: "negotiated receive buffer",
source,
},
machine,
}));
}
let dscp_result = apply_traffic_class_to_tokio_socket(
&self.socket,
self.remote,
negotiated_traffic_class,
);
if let Err(primary) = dscp_result {
self.restore_dscp_best_effort(self.applied_traffic_class);
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
}
Ok(PreparedAsyncOpen {
machine,
recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class),
})
}
fn commit_async_open(&mut self, prepared: PreparedAsyncOpen) -> OpenOutcome {
if let Some(recv_buffer_len) = prepared.recv_buffer_len {
self.recv_buffer.resize(recv_buffer_len, 0);
}
let outcome = self.machine.commit_open(prepared.machine);
self.applied_traffic_class = prepared.negotiated_traffic_class;
self.prepared_open = None;
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
outcome
}
/// See `Client::drain_tx_timestamps` in `client.rs`: same bounded,
/// nonblocking, best-effort drain, adapted to the Tokio socket.
fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
if !self.tx_timestamping_enabled {
return Ok(());
}
let machine = &mut self.machine;
drain_tx_timestamps(&self.socket, |id, timestamp| {
machine.record_kernel_tx_timestamp(id, timestamp);
})
.map_err(ClientError::Socket)
}
fn poll_open_cleanup(
&self,
state: &mut AsyncOpenState,
cx: &mut Context<'_>,
) -> Poll<Result<OpenOutcome, ClientError>> {
let Some(packet) = state
.cleanup
.as_ref()
.expect("cleanup polling requires a retained primary error")
.packet()
else {
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
};
let send_result = self.socket.try_send(packet);
match send_result {
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
}
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Ok(true)) => {
// Retain cleanup state and resume the send in a fresh poll. A
// writable readiness false positive must not spin this future.
cx.waker().wake_by_ref();
Poll::Pending
}
Poll::Ready(Ok(false) | Err(_)) => {
Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()))
}
}
}
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
let _ = match previous_traffic_class {
Some(traffic_class) => {
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, traffic_class)
}
None => clear_dscp_on_tokio_socket(&self.socket, self.remote),
};
}
}
struct DscpRollback<'a> {
socket: &'a tokio::net::UdpSocket,
remote: SocketAddr,
previous_traffic_class: Option<u8>,
armed: bool,
}
impl<'a> DscpRollback<'a> {
fn armed(
socket: &'a tokio::net::UdpSocket,
remote: SocketAddr,
previous_traffic_class: Option<u8>,
) -> Self {
Self {
socket,
remote,
previous_traffic_class,
armed: true,
}
}
fn restore(&mut self) -> Result<(), ClientError> {
if !self.armed {
return Ok(());
}
match self.previous_traffic_class {
Some(traffic_class) => {
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
}
None => clear_dscp_on_tokio_socket(self.socket, self.remote)?,
}
self.armed = false;
Ok(())
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for DscpRollback<'_> {
fn drop(&mut self) {
if self.armed {
let _ = self.restore();
}
}
}
fn poll_writable_until(
socket: &tokio::net::UdpSocket,
deadline: &mut Pin<Box<time::Sleep>>,
cx: &mut Context<'_>,
) -> Poll<Result<bool, ClientError>> {
match socket.poll_send_ready(cx) {
Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
Poll::Pending => match deadline.as_mut().poll(cx) {
Poll::Ready(()) => Poll::Ready(Ok(false)),
Poll::Pending => Poll::Pending,
},
}
}
fn poll_readable_until(
socket: &tokio::net::UdpSocket,
deadline: &mut Pin<Box<time::Sleep>>,
cx: &mut Context<'_>,
) -> Poll<Result<bool, ClientError>> {
match socket.poll_recv_ready(cx) {
Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
Poll::Pending => match deadline.as_mut().poll(cx) {
Poll::Ready(()) => Poll::Ready(Ok(false)),
Poll::Pending => Poll::Pending,
},
}
}
+44
View File
@@ -0,0 +1,44 @@
use std::{fmt, sync::Arc};
/// Shared HMAC key bytes. Debug output never includes the bytes or a fingerprint.
///
/// An empty key is still an explicitly configured HMAC key.
#[derive(Clone, PartialEq, Eq)]
pub struct HmacKey(Arc<[u8]>);
impl HmacKey {
/// Store key bytes without interpreting textual key syntax.
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
Self(bytes.into())
}
/// Explicitly borrow the key material for authentication.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for HmacKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("HmacKey([REDACTED])")
}
}
/// Concrete authentication for a low-level client session.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Authentication {
/// Send and accept packets without HMAC authentication.
#[default]
Unauthenticated,
/// Authenticate packets with this key, including when it is empty.
Hmac(HmacKey),
}
impl Authentication {
pub(crate) fn hmac_key(&self) -> Option<&[u8]> {
match self {
Self::Unauthenticated => None,
Self::Hmac(key) => Some(key.as_bytes()),
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,147 +0,0 @@
use super::*;
#[test]
fn open_fails_after_close() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
server.join();
}
#[test]
fn close_sends_one_close_packet_with_negotiated_token() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let events = client.close().unwrap();
assert_eq!(events.len(), 1);
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE);
assert_eq!(u64::from_le_bytes(close[4..12].try_into().unwrap()), TOKEN);
server.join();
}
#[test]
fn send_probe_fails_after_close() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
loop {
let mut buf = [0_u8; 512];
match socket.recv_from(&mut buf) {
Ok((size, _)) => {
tx.send(buf[..size].to_vec()).unwrap();
}
Err(_) => break,
}
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.first(),
Some(ClientEvent::EchoReply { .. })
));
assert!(matches!(
events.get(1),
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
));
assert_eq!(events.len(), 2);
assert!(client.next_send_deadline().is_none());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
#[test]
fn normal_echo_reply_does_not_close_session() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
assert!(client.next_send_deadline().is_some());
assert!(client.send_probe().is_ok());
client.close().unwrap();
server.join();
}
@@ -1,186 +0,0 @@
use super::*;
#[test]
fn client_config_default() {
let config = ClientConfig::default();
assert_eq!(config.duration, Some(Duration::from_secs(3)));
assert_eq!(config.interval, Duration::from_secs(1));
assert_eq!(config.length, 0);
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.dscp, 0);
assert_eq!(config.hmac_key, None);
assert_eq!(config.server_fill, None);
assert_eq!(config.open_timeouts, DEFAULT_OPEN_TIMEOUTS);
assert_eq!(config.run_mode, RunMode::Normal);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
assert_eq!(config.probe_timeout, Duration::from_secs(4));
assert_eq!(config.max_pending_probes, 4096);
}
#[test]
fn params_from_config_maps_compatibility_fields() {
let config = ClientConfig {
duration: Some(Duration::from_secs(5)),
interval: Duration::from_millis(250),
length: 1472,
received_stats: ReceivedStats::Window,
stamp_at: StampAt::Midpoint,
clock: Clock::Wall,
dscp: 46,
hmac_key: Some(b"secret".to_vec()),
server_fill: Some("rand".to_owned()),
..ClientConfig::default()
};
let params = params_from_config(&config).unwrap();
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
assert_eq!(params.duration_ns, 5_000_000_000);
assert_eq!(params.interval_ns, 250_000_000);
assert_eq!(params.length, 1472);
assert_eq!(params.received_stats, ReceivedStats::Window);
assert_eq!(params.stamp_at, StampAt::Midpoint);
assert_eq!(params.clock, Clock::Wall);
assert_eq!(params.dscp, 46, "config DSCP codepoint must not be shifted");
assert_eq!(
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
Some("rand")
);
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
}
#[test]
fn params_from_config_accepts_boundary_values() {
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
let config = ClientConfig {
length,
..ClientConfig::default()
};
assert_eq!(
params_from_config(&config).unwrap().length,
i64::from(length)
);
}
let config = ClientConfig {
dscp: 63,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
fn params_from_config_encodes_continuous_duration_as_zero() {
let config = ClientConfig {
duration: None,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
}
#[test]
fn params_from_config_rejects_invalid_values() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let cases = [
(
"oversized UDP payload",
ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..ClientConfig::default()
},
"packet length",
),
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
},
"duration must be greater than zero; use None for continuous mode",
),
(
"zero interval",
ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
},
"interval must be greater than zero",
),
(
"duration nanosecond overflow",
ClientConfig {
duration: Some(too_large),
..ClientConfig::default()
},
"duration is too large to encode as nanoseconds",
),
(
"interval nanosecond overflow",
ClientConfig {
interval: too_large,
..ClientConfig::default()
},
"interval is too large to encode as nanoseconds",
),
(
"invalid DSCP codepoint",
ClientConfig {
dscp: 64,
..ClientConfig::default()
},
"dscp",
),
(
"empty server fill",
ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
(
"oversized server fill",
ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
];
for (name, config, expected_reason) in cases {
assert!(
matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
),
"{name} should fail with InvalidConfig containing {expected_reason:?}"
);
}
}
#[test]
fn minimum_open_timeout_under_200ms_is_rejected() {
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(199)],
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::OpenTimeoutTooSmall { .. })
));
}
#[test]
fn empty_open_timeouts_is_rejected() {
let config = ClientConfig {
open_timeouts: vec![],
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::NoOpenTimeouts)
));
}
@@ -1,75 +0,0 @@
use super::*;
use crate::socket_options::socket_traffic_class;
#[test]
#[cfg(not(any(
target_os = "fuchsia",
target_os = "redox",
target_os = "solaris",
target_os = "illumos",
target_os = "haiku",
)))]
fn normal_open_applies_negotiated_dscp_after_open_and_close_clears_it() {
let mut params = default_params();
params.dscp = 46;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut config = default_test_config(server.addr);
config.dscp = 46;
let mut client = Client::connect(config).unwrap();
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
0
);
assert_open_started(client.open().unwrap());
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
184
);
client.close().unwrap();
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
);
server.join();
}
#[test]
#[cfg(not(any(
target_os = "fuchsia",
target_os = "redox",
target_os = "solaris",
target_os = "illumos",
target_os = "haiku",
)))]
fn normal_open_uses_negotiated_dscp_not_requested_dscp() {
let mut returned = default_params();
returned.dscp = 0;
let server = open_success_server(returned);
let mut config = default_test_config(server.addr);
config.dscp = 46;
config.negotiation_policy = NegotiationPolicy::Loose;
let mut client = Client::connect(config).unwrap();
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params.dscp, 0);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
);
server.join();
}
-184
View File
@@ -1,184 +0,0 @@
use super::*;
#[test]
fn hmac_open_success() {
let key = b"secret".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key.clone());
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (request, peer) = recv_request(&socket, &tx);
verify_hmac(&key, &request, HMAC_OFFSET).unwrap();
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(&key));
socket.send_to(&reply, peer).unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
server.join();
}
#[test]
fn hmac_open_rejects_missing_hmac() {
let key = b"secret".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key);
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::Protocol(
irtt_proto::ProtoError::HmacPresenceMismatch
))
));
server.join();
}
#[test]
fn hmac_open_rejects_bad_hmac() {
let key = b"secret".to_vec();
let wrong_key = b"wrong".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key);
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(&wrong_key));
socket.send_to(&reply, peer).unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::BadHmac))
));
server.join();
}
#[test]
fn hmac_close_packet_includes_valid_hmac() {
let key = b"secret".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key.clone());
let params = params_from_config(&config).unwrap();
let server_key = key.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(&server_key));
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
verify_hmac(&key, close, HMAC_OFFSET).unwrap();
assert_eq!(
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
TOKEN
);
server.join();
}
#[test]
fn bad_hmac_reply_is_dropped() {
let key = b"secret".to_vec();
let wrong_key = b"wrong".to_vec();
let params = default_params();
let server_key = key.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(&server_key));
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
.try_into()
.unwrap(),
);
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, Some(&wrong_key));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
hmac_key: Some(key),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
server.join();
}
#[test]
fn hmac_echo_request_reply_works() {
let key = b"testkey".to_vec();
let params = default_params();
let server_key = key.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(&server_key));
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
verify_hmac(&server_key, &buf[..size], HMAC_OFFSET).unwrap();
let seq = u32::from_le_bytes(
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
.try_into()
.unwrap(),
);
let ts = TimestampFields {
recv_mono: Some(100),
send_mono: Some(200),
..Default::default()
};
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, Some(&server_key));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
hmac_key: Some(key),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
server.join();
}
@@ -1,21 +0,0 @@
use super::*;
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
use irtt_proto::{
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_hmac, Clock, ReceivedStats,
StampAt, HMAC_SIZE, MAGIC,
};
use std::{thread, time::SystemTime};
mod support;
use support::*;
mod close;
mod config;
mod dscp;
mod hmac;
mod negotiation;
mod no_test;
mod open;
mod probes_replies;
mod sequence;
mod ttl;
@@ -1,268 +0,0 @@
use super::*;
use crate::NegotiationRestriction;
fn assert_negotiates(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> NegotiatedParams {
negotiate_params(requested, returned.clone(), policy)
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
}
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
match negotiate_params(requested, returned.clone(), policy) {
Err(ClientError::NegotiationRejected { reason }) => reason,
other => panic!("expected negotiation rejection, got {other:?}"),
}
}
#[test]
fn strict_negotiation_accepts_identical_params() {
let config = ClientConfig::default();
let params = params_from_config(&config).unwrap();
let negotiated = assert_negotiates(&params, &params, NegotiationPolicy::Strict);
assert_eq!(negotiated.params, params);
assert!(negotiated.restrictions.is_empty());
}
#[test]
fn strict_negotiation_rejects_changed_params() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
}
.message()
);
}
#[test]
fn loose_negotiation_accepts_server_restricted_params() {
let config = ClientConfig::default();
let mut requested = params_from_config(&config).unwrap();
requested.length = 128;
let mut returned = requested.clone();
returned.duration_ns /= 2;
returned.length = 0;
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
},
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
},
]
);
}
#[test]
fn loose_negotiation_rejects_non_positive_returned_interval() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
for interval_ns in [0, -1] {
let mut returned = requested.clone();
returned.interval_ns = interval_ns;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"interval must be positive"
);
}
}
#[test]
fn loose_negotiation_accepts_documented_duration_combinations() {
let finite_config = ClientConfig::default();
let requested_finite = params_from_config(&finite_config).unwrap();
let returned_same_finite = requested_finite.clone();
assert!(assert_negotiates(
&requested_finite,
&returned_same_finite,
NegotiationPolicy::Loose
)
.restrictions
.is_empty());
let mut returned_shorter_finite = requested_finite.clone();
returned_shorter_finite.duration_ns /= 2;
assert_eq!(
assert_negotiates(
&requested_finite,
&returned_shorter_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_finite.duration_ns,
negotiated_ns: returned_shorter_finite.duration_ns,
}]
);
let continuous_config = ClientConfig {
duration: None,
..ClientConfig::default()
};
let requested_continuous = params_from_config(&continuous_config).unwrap();
let returned_continuous = requested_continuous.clone();
assert!(assert_negotiates(
&requested_continuous,
&returned_continuous,
NegotiationPolicy::Loose
)
.restrictions
.is_empty());
let mut returned_finite = requested_continuous.clone();
returned_finite.duration_ns = 1_000_000_000;
assert_eq!(
assert_negotiates(
&requested_continuous,
&returned_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_continuous.duration_ns,
negotiated_ns: returned_finite.duration_ns,
}]
);
}
#[test]
fn loose_negotiation_rejects_finite_request_returned_continuous() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.duration_ns = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned continuous duration for finite request"
);
}
#[test]
fn loose_negotiation_rejects_negative_returned_length() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"length must be non-negative"
);
}
#[test]
fn strict_negotiation_rejects_negative_returned_length() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"length must be non-negative"
);
}
#[test]
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
for dscp in [-1, 64] {
let mut returned = requested.clone();
returned.dscp = dscp;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"dscp must be in range 0..=63"
);
}
}
#[test]
fn loose_negotiation_records_dscp_disabled_by_server() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
}
#[test]
fn strict_negotiation_rejects_dscp_disabled_by_server_as_specific_restriction() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}
.message()
);
}
#[test]
fn negotiation_rejects_unsupported_dscp_changes() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 8;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"server returned unsupported DSCP change"
);
let zero_config = ClientConfig::default();
let zero_requested = params_from_config(&zero_config).unwrap();
let mut returned = zero_requested.clone();
returned.dscp = 46;
assert_eq!(
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
}
@@ -1,112 +0,0 @@
use super::*;
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
server.join();
}
#[test]
fn no_test_rejects_non_close_open_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::UnexpectedNoTestReply)
));
server.join();
}
#[test]
fn no_test_rejects_non_zero_token_with_close_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params, TOKEN);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
));
server.join();
}
#[test]
fn no_test_strict_negotiation_rejects_changed_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let mut params = params_from_config(&config).unwrap();
params.dscp = 1;
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn no_test_loose_negotiation_accepts_restricted_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
config.negotiation_policy = NegotiationPolicy::Loose;
let requested = params_from_config(&config).unwrap();
let mut params = requested.clone();
params.duration_ns /= 2;
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: params.duration_ns,
}]
);
server.join();
}
#[test]
fn send_probe_fails_after_no_test_completed() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open().unwrap());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyCompleted)
));
server.join();
}
#[test]
fn open_fails_after_no_test_completed() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
server.join();
}
@@ -1,87 +0,0 @@
use super::*;
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
server.join();
}
#[test]
fn open_fails_when_already_open() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
server.join();
}
#[test]
fn open_retries_after_first_timeout() {
let server = start_fake_server(|socket, tx| {
let (first, _) = recv_request(&socket, &tx);
let (_, peer) = recv_request(&socket, &tx);
let params = Params::decode(&first[4..]).unwrap();
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
socket.send_to(&reply, peer).unwrap();
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert_open_started(outcome);
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
}
#[test]
fn open_timeout_after_all_timeouts() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
}
#[test]
fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.negotiation_policy = NegotiationPolicy::Loose;
let mut params = params_from_config(&config).unwrap();
params.protocol_version = 2;
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
));
server.join();
}
#[test]
fn server_rejection_fails_in_normal_mode() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, &params, None);
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
server.join();
}
@@ -1,121 +0,0 @@
use super::*;
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now0).is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now1).is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client.send_probe_at(now2).unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
@@ -1,140 +0,0 @@
use super::*;
#[test]
fn recv_once_returns_empty_on_timeout() {
let params = default_params();
let server = open_success_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.recv_once().unwrap();
assert!(events.is_empty());
server.join();
}
#[test]
fn recv_once_decodes_echo_reply_and_emits_event() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
seq,
rtt,
received_stats,
server_timing,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*bytes, echo_packet_len(false, &params));
assert!(rtt.raw > Duration::ZERO);
assert_eq!(
rtt.effective,
rtt.adjusted
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
);
assert!(received_stats.is_some());
let stats = received_stats.as_ref().unwrap();
assert_eq!(stats.count, Some(42));
assert_eq!(stats.window, Some(0x07));
assert!(server_timing.is_some());
let st = server_timing.as_ref().unwrap();
assert!(st.processing.is_some());
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
{
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
{
assert_eq!(
packet_meta.dscp,
packet_meta.traffic_class.map(|tc| tc >> 2)
);
assert_eq!(
packet_meta.ecn,
packet_meta.traffic_class.map(|tc| tc & 0b11)
);
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
let params = default_params();
let server_params = params.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
socket.send_to(&reply, peer).unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let (packet, _) = recv_request(&socket, &tx);
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
}
let mut longer = echo_reply_packet(
TOKEN,
seqs[0],
&server_params,
&TimestampFields::default(),
None,
);
longer.extend_from_slice(b"trailing bytes");
socket.send_to(&longer, peer).unwrap();
let shorter = echo_reply_packet(
TOKEN,
seqs[1],
&server_params,
&TimestampFields::default(),
None,
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = client.recv_once().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { seq, bytes, .. } => {
assert_eq!(*seq, 1);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
}
server.join();
}
@@ -1,157 +0,0 @@
use super::*;
#[test]
fn server_processing_greater_than_raw_does_not_underflow() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(1),
wall: SystemTime::now(),
},
&TimestampFields {
recv_mono: Some(0),
send_mono: Some(1_000_000_000),
..Default::default()
},
);
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
}
#[test]
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(42),
wall: SystemTime::now(),
},
&TimestampFields::default(),
);
assert_eq!(rtt.adjusted, None);
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
}
#[test]
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
let overflow = max + Duration::from_nanos(1);
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
assert_eq!(unix_epoch_ns_i64(overflow), None);
}
#[test]
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let overflow_wall =
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: overflow_wall,
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: overflow_wall + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(i64::MAX),
send_wall: Some(i64::MAX),
..Default::default()
};
assert_eq!(compute_one_way(&sent_at, &received_at, &ts), None);
}
#[test]
fn compute_one_way_returns_available_direction_samples() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_client_to_server_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 - 5_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(-5_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_server_to_client_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 45_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(-5_000_000))
);
}
@@ -1,27 +0,0 @@
use super::*;
#[test]
fn sequence_ordering_handles_normal_and_wrapped_values() {
assert!(sequence_is_after(11, 10));
assert!(sequence_is_before(9, 10));
assert!(!sequence_is_after(10, 10));
assert!(!sequence_is_before(10, 10));
assert!(sequence_is_after(0, u32::MAX - 1));
assert!(!sequence_is_before(0, u32::MAX - 1));
assert!(sequence_is_after(1, u32::MAX));
assert!(!sequence_is_before(1, u32::MAX));
assert!(sequence_is_before(u32::MAX, 1));
assert!(!sequence_is_after(u32::MAX, 1));
}
#[test]
fn highest_received_seq_updates_across_wrap() {
let mut highest = Some(u32::MAX);
update_highest_received(&mut highest, 0);
assert_eq!(highest, Some(0));
update_highest_received(&mut highest, u32::MAX);
assert_eq!(highest, Some(0));
}
@@ -1,49 +0,0 @@
use crate::socket_options::socket_ttl;
use super::*;
#[test]
fn connect_accepts_valid_ttl_config_values() {
for ttl in [None, Some(1), Some(255)] {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
config.socket_config.ttl = ttl;
Client::connect(config).unwrap();
}
}
#[test]
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
let params = Params {
protocol_version: PROTOCOL_VERSION,
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()
};
let server = open_success_server(params.clone());
let mut config = default_test_config(server.addr);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
client.open().unwrap();
server.join();
client.close().unwrap();
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
}
#[test]
fn connect_rejects_invalid_ttl_values() {
for ttl in [0, 256] {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
config.socket_config.ttl = Some(ttl);
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
+205 -86
View File
@@ -1,10 +1,12 @@
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
use crate::{Authentication, ClientError};
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8 /// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
/// bytes. /// bytes.
/// ///
/// This is the maximum encoded server-fill string accepted by this client and /// This is the maximum encoded server-fill string accepted by this client and
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound /// protocol decoder. [`SessionRequest::server_fill`] enforces the same bound
/// before opening a session. /// before opening a session.
pub use irtt_proto::MAX_SERVER_FILL_BYTES; pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt}; use irtt_proto::{Clock, ReceivedStats, StampAt};
@@ -12,8 +14,10 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
pub(crate) const DEFAULT_PORT: u16 = 2112; pub(crate) const DEFAULT_PORT: u16 = 2112;
/// Largest valid DSCP codepoint accepted by client configuration. /// Largest valid DSCP codepoint accepted by client configuration.
/// ///
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the /// DSCP is a six-bit value in the range `0..=63`. The client shifts it left
/// socket traffic-class field when configuring packet priority. /// by two bits into the raw IP TOS / Traffic Class byte carried by
/// [`irtt_proto::Params::dscp`] and applied to the socket during the active
/// test phase.
pub const MAX_DSCP_CODEPOINT: u8 = 63; pub const MAX_DSCP_CODEPOINT: u8 = 63;
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration. /// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
/// ///
@@ -24,7 +28,7 @@ pub const MAX_TTL: u32 = 255;
/// Largest UDP payload length accepted by client configuration, in bytes. /// Largest UDP payload length accepted by client configuration, in bytes.
/// ///
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps /// This is the maximum UDP payload size excluding IP and UDP headers. It caps
/// [`ClientConfig::length`] before protocol packets are encoded or sent. /// [`SessionRequest::length`] before protocol packets are encoded or sent.
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507; pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3); pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1); pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
@@ -43,68 +47,51 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// This type describes both the protocol parameters sent in the IRTT open /// This type describes both the protocol parameters sent in the IRTT open
/// request and the local client behavior used to drive the UDP socket. Values /// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session /// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams). /// through [`NegotiationResult`](crate::NegotiationResult).
///
/// # Example
///
/// Configure a finite run with larger probes and accept documented server
/// restrictions. Use `duration: None` for continuous mode, or
/// [`RunMode::NoTest`] for negotiation without probes.
///
/// ```
/// use std::time::Duration;
///
/// use irtt_client::{ClientConfig, NegotiationPolicy, OpenPolicy, SessionRequest};
///
/// let config = ClientConfig {
/// request: SessionRequest {
/// duration: Some(Duration::from_secs(10)),
/// interval: Duration::from_millis(250),
/// length: 1200,
/// ..SessionRequest::default()
/// },
/// open: OpenPolicy {
/// negotiation: NegotiationPolicy::Loose,
/// ..OpenPolicy::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.request.duration, Some(Duration::from_secs(10)));
/// assert_eq!(config.request.interval, Duration::from_millis(250));
/// assert_eq!(config.request.length, 1200);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig { pub struct ClientConfig {
/// Remote server name or address. /// Remote address families allowed during resolution and socket creation.
pub address_family: AddressFamily,
/// Local UDP socket properties.
pub socket: SocketConfig,
/// Protocol and session values requested from the server.
pub request: SessionRequest,
/// Open retry timeouts and negotiation policy.
pub open: OpenPolicy,
/// Concrete authentication for open, echo, and close packets.
/// ///
/// If no port is present, the default IRTT port 2112 is used. IPv6 /// HMAC peers must use the same key. An empty HMAC key remains authenticated.
/// literals may be supplied either bracketed or unbracketed when the pub auth: Authentication,
/// default port should be used.
pub server_addr: String,
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8,
/// Optional HMAC key used to authenticate IRTT packets.
///
/// When present, open, echo, and close packets are encoded and decoded with
/// HMAC authentication. The peer must be configured with the same key.
pub hmac_key: Option<Vec<u8>>,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>,
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub open_timeouts: Vec<Duration>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation_policy: NegotiationPolicy,
/// Local UDP socket configuration.
pub socket_config: SocketConfig,
/// Time after sending a probe before the client reports it as lost. /// Time after sending a probe before the client reports it as lost.
/// ///
/// This timeout is local client behavior; it is not negotiated with the /// This timeout is local client behavior; it is not negotiated with the
@@ -121,7 +108,85 @@ pub struct ClientConfig {
impl Default for ClientConfig { impl Default for ClientConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
server_addr: format!("127.0.0.1:{DEFAULT_PORT}"), address_family: AddressFamily::default(),
socket: SocketConfig::default(),
request: SessionRequest::default(),
open: OpenPolicy::default(),
auth: Authentication::Unauthenticated,
probe_timeout: DEFAULT_PROBE_TIMEOUT,
max_pending_probes: DEFAULT_MAX_PENDING,
}
}
}
/// Allowed remote address families. IPv6-only selection also sets IPV6_V6ONLY.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddressFamily {
/// Accept IPv4 and IPv6 addresses.
#[default]
Any,
/// Accept only IPv4 addresses.
Ipv4,
/// Accept only IPv6 addresses and use IPv6-only sockets.
Ipv6,
}
/// Requested IRTT protocol and session semantics, reusable across endpoints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionRequest {
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
///
/// The managed driver stops sending after the negotiated duration.
/// Low-level adapters leave run duration to the caller.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
///
/// The managed driver uses the negotiated interval for cadence.
/// Low-level adapters send whenever called.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
///
/// [`Clock::Unspecified`] is the wire default meaning "no clock tag was
/// sent" and is not a client mode; opening a session with it is rejected.
/// Request [`Clock::Wall`], [`Clock::Monotonic`], or [`Clock::Both`].
pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode,
}
impl Default for SessionRequest {
fn default() -> Self {
Self {
duration: Some(DEFAULT_DURATION), duration: Some(DEFAULT_DURATION),
interval: DEFAULT_INTERVAL, interval: DEFAULT_INTERVAL,
length: 0, length: 0,
@@ -129,23 +194,78 @@ impl Default for ClientConfig {
stamp_at: StampAt::Both, stamp_at: StampAt::Both,
clock: Clock::Both, clock: Clock::Both,
dscp: 0, dscp: 0,
hmac_key: None,
server_fill: None, server_fill: None,
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
run_mode: RunMode::Normal, run_mode: RunMode::Normal,
negotiation_policy: NegotiationPolicy::Strict, }
socket_config: SocketConfig::default(), }
probe_timeout: DEFAULT_PROBE_TIMEOUT, }
max_pending_probes: DEFAULT_MAX_PENDING,
/// Opening behavior shared by blocking, async, and managed clients.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenPolicy {
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub timeouts: Vec<Duration>,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation: NegotiationPolicy,
}
impl OpenPolicy {
pub(crate) fn validate(&self) -> Result<(), ClientError> {
if self.timeouts.is_empty() {
return Err(ClientError::NoOpenTimeouts);
}
for timeout in &self.timeouts {
if *timeout < MIN_OPEN_TIMEOUT {
return Err(ClientError::OpenTimeoutTooSmall {
timeout: *timeout,
minimum: MIN_OPEN_TIMEOUT,
});
}
}
Ok(())
}
}
impl Default for OpenPolicy {
fn default() -> Self {
Self {
timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
negotiation: NegotiationPolicy::Strict,
} }
} }
} }
/// Local UDP socket options used by [`ClientConfig`]. /// Local UDP socket options used by [`ClientConfig`].
/// ///
/// These settings affect how the client binds, resolves, and receives from the /// These settings affect how the client binds and sends from the socket. They
/// socket. They do not change the IRTT protocol parameters negotiated with the /// do not change the IRTT protocol parameters negotiated with the server.
/// server. ///
/// # Example
///
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{AddressFamily, ClientConfig, SocketConfig};
///
/// let config = ClientConfig {
/// address_family: AddressFamily::Ipv4,
/// socket: SocketConfig {
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
/// ttl: Some(32),
/// ..SocketConfig::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.socket.ttl, Some(32));
/// assert_eq!(config.address_family, AddressFamily::Ipv4);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig { pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket. /// Local address to bind before connecting the UDP socket.
@@ -153,23 +273,21 @@ pub struct SocketConfig {
/// `None` binds to an unspecified address with an ephemeral port matching /// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family. /// the selected remote address family.
pub bind_addr: Option<SocketAddr>, pub bind_addr: Option<SocketAddr>,
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub bind_to_device: Option<String>,
/// Firewall mark applied to outgoing packets (`SO_MARK`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub mark: Option<u32>,
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
#[cfg(target_os = "freebsd")]
pub fib: Option<u32>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets. /// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
/// ///
/// `None` leaves the platform default unchanged. Values must fit in the /// `None` leaves the platform default unchanged. Values must fit in the
/// platform socket option range; [`MAX_TTL`] is the public configuration /// platform socket option range; [`MAX_TTL`] is the public configuration
/// bound used by this crate. /// bound used by this crate.
pub ttl: Option<u32>, pub ttl: Option<u32>,
/// Restrict name resolution to IPv4 addresses.
pub ipv4_only: bool,
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
/// sockets.
pub ipv6_only: bool,
/// Socket read timeout used after the session is open.
///
/// `None` leaves reads blocking for APIs that perform a single receive.
/// Managed sessions may replace `None` or long timeouts with a short
/// timeout so cooperative cancellation can be observed promptly.
pub recv_timeout: Option<Duration>,
} }
/// How strictly to handle server-side negotiation restrictions. /// How strictly to handle server-side negotiation restrictions.
@@ -179,14 +297,15 @@ pub enum NegotiationPolicy {
/// than requested. /// than requested.
Strict, Strict,
/// Accept documented server restrictions and report them in /// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams). /// [`NegotiationResult`](crate::NegotiationResult).
Loose, Loose,
} }
/// Mode requested during the IRTT open exchange. /// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode { pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration. /// Open a probe session. The managed driver runs for the negotiated duration;
/// low-level adapters leave sending and stopping to the caller.
Normal, Normal,
/// Complete negotiation without running the echo probe test. /// Complete negotiation without running the echo probe test.
NoTest, NoTest,
+38 -26
View File
@@ -1,4 +1,4 @@
use std::{io, time::Duration}; use std::{collections::TryReserveError, io, time::Duration};
use thiserror::Error; use thiserror::Error;
@@ -12,6 +12,10 @@ use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum ClientError { pub enum ClientError {
/// A Tokio-backed client operation was polled without a current runtime.
#[cfg(feature = "tokio")]
#[error("Tokio AsyncClient requires a current Tokio runtime")]
NoTokioRuntime,
/// The configured server address could not be resolved to a socket address. /// The configured server address could not be resolved to a socket address.
/// ///
/// Check the host, port, address-family restrictions, and name service /// Check the host, port, address-family restrictions, and name service
@@ -38,8 +42,8 @@ pub enum ClientError {
}, },
/// Restoring the configured read timeout after open negotiation failed. /// Restoring the configured read timeout after open negotiation failed.
/// ///
/// The session may have opened successfully, but the socket could not be /// The prepared session was not committed, and the client remains
/// returned to the caller's requested receive-timeout behavior. /// connected rather than partially open.
#[error("failed to restore configured socket read timeout after open negotiation: {source}")] #[error("failed to restore configured socket read timeout after open negotiation: {source}")]
ReadTimeoutRestore { source: io::Error }, ReadTimeoutRestore { source: io::Error },
/// A packet failed IRTT protocol parsing or authentication. /// A packet failed IRTT protocol parsing or authentication.
@@ -146,32 +150,40 @@ pub enum ClientError {
/// sending again. /// sending again.
#[error("pending probe limit exceeded ({limit})")] #[error("pending probe limit exceeded ({limit})")]
PendingLimitExceeded { limit: usize }, PendingLimitExceeded { limit: usize },
/// A wrapping wire sequence is still represented by an in-flight probe.
///
/// The existing probe must be replied to or timed out before that sequence
/// can be reused.
#[error("wire sequence {seq} is still pending")]
PendingSequenceCollision { seq: u32 },
/// A prepared probe no longer matches the session's next wire sequence.
///
/// Prepare the probe again after any intervening successful send.
#[error(
"prepared probe sequence {prepared_seq} is stale; next wire sequence is {next_wire_seq}"
)]
StalePreparedProbe {
prepared_seq: u32,
next_wire_seq: u32,
},
/// Reserving memory required by an operation failed before any datagram was
/// sent or logical protocol state was changed.
#[error("failed to reserve memory for {operation}: {source}")]
AllocationFailed {
operation: &'static str,
#[source]
source: TryReserveError,
},
/// UDP reported successful acceptance of a datagram with an unexpected
/// byte count.
///
/// The send is committed locally before this transport invariant failure
/// is returned because the kernel reported success.
#[error("UDP accepted {actual} bytes for a {expected}-byte datagram")]
DatagramLengthMismatch { expected: usize, actual: usize },
/// Client configuration failed validation before the requested operation. /// Client configuration failed validation before the requested operation.
/// ///
/// The reported reason names a value that must be changed before retrying. /// The reported reason names a value that must be changed before retrying.
#[error("invalid configuration: {reason}")] #[error("invalid configuration: {reason}")]
InvalidConfig { reason: String }, InvalidConfig { reason: String },
/// A managed-session worker thread panicked before producing an outcome.
///
/// This is a runtime failure from joining the managed session, not a
/// protocol response from the server.
#[error("managed client worker thread panicked")]
WorkerPanicked,
}
/// Failure reported while setting up or receiving from an event subscription.
///
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
/// Once a subscription exists, this error type reports delivery failures observed
/// by [`EventSubscription`](crate::EventSubscription).
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EventSubscriptionError {
/// The subscription can no longer receive new events.
///
/// Already queued events are delivered first. After the queue is drained,
/// `recv` and `try_recv` return this error when the hub disconnects the
/// subscriber, the hub is shut down, or the subscription is disconnected by
/// its overflow policy.
#[error("event subscription is disconnected")]
Disconnected,
} }
+98 -60
View File
@@ -3,35 +3,36 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use crate::{session::NegotiatedParams, timing::ClientTimestamp}; use crate::{session::NegotiationResult, timing::ClientTimestamp};
/// Result of the IRTT open exchange. /// Successful negotiation with a live session ready for echo probes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionStarted {
/// Resolved remote socket address.
pub remote: SocketAddr,
/// Session token used on Echo and Close packets.
pub token: u64,
pub negotiation: NegotiationResult,
/// Client timestamp when the Open reply was accepted.
pub at: ClientTimestamp,
}
/// Successful negotiation-only exchange with no live probe session or token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoTestCompleted {
pub remote: SocketAddr,
pub negotiation: NegotiationResult,
/// Client timestamp when the Open reply was accepted.
pub at: ClientTimestamp,
}
/// Result of the IRTT Open exchange, before any echo probes are driven.
/// ///
/// The lower-level [`Client`](crate::Client) API returns this before any echo /// Managed sessions publish the same payload as a [`ClientEvent`] after Open.
/// probes are driven. Managed sessions publish the contained lifecycle event to
/// subscribers immediately after a successful open.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenOutcome { pub enum OpenOutcome {
/// A normal probe session was opened. Started(SessionStarted),
Started { NoTestCompleted(NoTestCompleted),
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Session token assigned by the server and used on echo/close packets.
token: u64,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
/// A no-test open exchange completed without starting a probe session.
NoTestCompleted {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
} }
/// Event emitted by an IRTT client session. /// Event emitted by an IRTT client session.
@@ -44,31 +45,16 @@ pub enum OpenOutcome {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent { pub enum ClientEvent {
/// A normal session has opened and is ready to send echo probes. /// A normal session has opened and is ready to send echo probes.
SessionStarted { SessionStarted(SessionStarted),
/// Resolved remote socket address. /// A no-test exchange completed without creating a live session.
remote: SocketAddr,
/// Session token assigned by the server.
token: u64,
/// Negotiated protocol parameters for this session.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// A negotiation-only no-test exchange completed.
/// ///
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are /// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
/// expected for this open outcome. /// expected for this outcome.
NoTestCompleted { NoTestCompleted(NoTestCompleted),
/// Resolved remote socket address. /// The session closed locally or through an authenticated peer close.
remote: SocketAddr,
/// Negotiated protocol parameters returned by the server.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// The client sent a close request and considers the session closed.
/// ///
/// This is a local lifecycle event, not an acknowledgement from the server. /// For a peer close-flagged echo reply, the reply's measurement or
/// classification event is emitted immediately before this event.
/// Managed sessions disconnect event subscribers after the worker exits, /// Managed sessions disconnect event subscribers after the worker exits,
/// while leaving already queued events available to drain. /// while leaving already queued events available to drain.
SessionClosed { SessionClosed {
@@ -91,17 +77,23 @@ pub enum ClientEvent {
seq: u32, seq: u32,
/// Resolved remote socket address. /// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent. /// Intended send deadline, supplied by the managed runner.
scheduled_at: Instant, /// Low-level adapters leave this absent.
/// Client wall/monotonic timestamp captured for the send. scheduled_at: Option<Instant>,
/// Paired client wall/monotonic timestamp captured immediately after
/// the successful UDP send completed. This is the RTT and userspace
/// upstream one-way-delay fallback endpoint. The operational probe
/// timeout deadline is computed from an earlier, private, pre-send
/// timestamp instead — see the client crate's `AGENTS.md`.
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Number of bytes passed to the UDP socket. /// Number of bytes passed to the UDP socket.
bytes: usize, bytes: usize,
/// Elapsed time spent in the socket send call. /// Elapsed time spent in the socket send call, excluding protocol and
/// scheduler commit work.
send_call: Duration, send_call: Duration,
/// Absolute difference between the scheduled send time and actual send /// Absolute difference between the scheduled send time and the
/// timestamp. /// post-send `sent_at` timestamp above; absent for low-level sends.
timer_error: Duration, timer_error: Option<Duration>,
}, },
/// First in-window reply for a pending echo request. /// First in-window reply for a pending echo request.
@@ -114,7 +106,8 @@ pub enum ClientEvent {
seq: u32, seq: u32,
/// Resolved remote socket address. /// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Client timestamp recorded when the matching request was sent. /// Client timestamp recorded immediately after the matching request
/// finished sending (see `ClientEvent::EchoSent::sent_at`).
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Client timestamp recorded when the reply was received. /// Client timestamp recorded when the reply was received.
received_at: ClientTimestamp, received_at: ClientTimestamp,
@@ -140,7 +133,10 @@ pub enum ClientEvent {
EchoLoss { EchoLoss {
/// Wire sequence number that timed out. /// Wire sequence number that timed out.
seq: u32, seq: u32,
/// Client timestamp recorded when the request was sent. /// Client timestamp recorded immediately after the request finished
/// sending (see `ClientEvent::EchoSent::sent_at`). The timeout
/// deadline below is anchored to an earlier, private, pre-send
/// timestamp, not to this one.
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Monotonic deadline at which the probe was declared lost. /// Monotonic deadline at which the probe was declared lost.
timeout_at: std::time::Instant, timeout_at: std::time::Instant,
@@ -175,7 +171,8 @@ pub enum ClientEvent {
highest_seen: u32, highest_seen: u32,
/// Resolved remote socket address. /// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Original client send timestamp, when still retained. /// Original post-send client timestamp, when still retained (see
/// `ClientEvent::EchoSent::sent_at`).
sent_at: Option<ClientTimestamp>, sent_at: Option<ClientTimestamp>,
/// Client timestamp recorded when the late reply was received. /// Client timestamp recorded when the late reply was received.
received_at: ClientTimestamp, received_at: ClientTimestamp,
@@ -249,6 +246,19 @@ pub struct RttSample {
/// processing time or comparing wall-clock timestamps from different hosts. It /// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time, /// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`]. /// which is non-negative and represented by [`Duration`].
///
/// # Example
///
/// Signed timing values retain a negative result instead of clamping clock
/// skew or server processing adjustments to zero:
///
/// ```
/// use irtt_client::SignedDuration;
///
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
/// assert!(adjusted_rtt.is_negative());
/// assert_eq!(adjusted_rtt.as_micros(), -250);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration { pub struct SignedDuration {
ns: i128, ns: i128,
@@ -340,12 +350,31 @@ pub struct OneWayDelaySample {
/// Client-to-server delay. /// Client-to-server delay.
/// ///
/// This is computed from the client send wall time and the server receive /// This is computed from the client send wall time and the server receive
/// wall time when both are available. /// wall time when both are available. The client send endpoint is the
/// correlated Linux kernel TX timestamp for this send when one is
/// available and plausible for this send, and the userspace post-send wall
/// time otherwise. The server receive endpoint is whatever wall time the
/// peer reported; against this crate's own Tokio server runtime on Linux
/// (the `irtt-server` applet), that is its kernel receive timestamp when
/// one is available and plausible, and its userspace receive wall time
/// otherwise. Against another IRTT-compatible peer, or an application
/// driving `irtt_server::ServerCore::handle_datagram` directly, the
/// client has no way to know which clock source produced it — the public
/// `ServerCore` API never supplies a kernel timestamp of its own accord.
/// `RttSample::raw` is unaffected and remains a userspace
/// monotonic measurement, but `adjusted` and `effective` can still shift:
/// when the session negotiated wall-clock timing rather than monotonic,
/// server processing time is derived from this same server receive
/// endpoint.
pub client_to_server: Option<SignedDuration>, pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay. /// Server-to-client delay.
/// ///
/// This is computed from the server send wall time and the client receive /// This is computed from the server send wall time and the client receive
/// wall time when both are available. /// wall time when both are available. The client receive endpoint is the
/// kernel receive timestamp reported with the datagram when one is
/// available and plausible for this receive, and the userspace receive wall
/// time otherwise. RTT is unaffected and remains a userspace monotonic
/// measurement.
pub server_to_client: Option<SignedDuration>, pub server_to_client: Option<SignedDuration>,
} }
@@ -358,7 +387,10 @@ pub struct OneWayDelaySample {
pub struct ReceivedStatsSample { pub struct ReceivedStatsSample {
/// Server-reported count of received packets, when available. /// Server-reported count of received packets, when available.
pub count: Option<u32>, pub count: Option<u32>,
/// Server-reported receive window value, when available. /// Raw server-reported receive window bitmap, when available.
///
/// The bitmap is preserved exactly as it appeared on the wire; the client
/// does not interpret its bits.
pub window: Option<u64>, pub window: Option<u64>,
} }
@@ -371,5 +403,11 @@ pub struct PacketMeta {
pub traffic_class: Option<u8>, pub traffic_class: Option<u8>,
pub dscp: Option<u8>, pub dscp: Option<u8>,
pub ecn: Option<u8>, pub ecn: Option<u8>,
/// Kernel software receive timestamp reported with the datagram, where the
/// platform provides one.
///
/// This is the raw observed value, reported whether or not it was accepted
/// as the downstream one-way delay endpoint. It is a kernel software
/// timestamp, not a NIC hardware ingress time.
pub kernel_rx_timestamp: Option<std::time::SystemTime>, pub kernel_rx_timestamp: Option<std::time::SystemTime>,
} }
+48 -47
View File
@@ -3,6 +3,8 @@
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives //! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`] //! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
//! values for callers to consume directly or aggregate with `irtt-stats`. //! values for callers to consume directly or aggregate with `irtt-stats`.
//! With the `tokio` feature enabled, `AsyncClient` provides the same low-level
//! lifecycle for callers that own a Tokio runtime and drive readiness directly.
//! //!
//! Timing values intentionally preserve signed measurement semantics. When //! Timing values intentionally preserve signed measurement semantics. When
//! server timing is available, [`RttSample::effective`] is adjusted for server //! server timing is available, [`RttSample::effective`] is adjusted for server
@@ -11,72 +13,71 @@
//! also signed when the required wall-clock timestamps are available; negative //! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew. //! values usually indicate client/server clock skew.
//! //!
//! A managed session can drive the socket loop on a worker thread and publish //! [`Client`] is the runtime-free low-level blocking adapter and requires no
//! events through a subscription: //! Tokio dependency at all; it is what a caller without a runtime, or that
//! wants full control over its own event loop, should start from. With the
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
//! adapter for a caller that owns a runtime and drives socket readiness
//! itself. Both adapters send one probe whenever called; callers own cadence
//! and run duration, using `negotiation()` after Open. Sends return a
//! [`SendReceipt`] after socket acceptance and session commitment;
//! [`SendProbeError`] distinguishes uncommitted failures from post-commit
//! failures carrying that receipt. Convert a receipt to [`ClientEvent`] for
//! presentation or statistics. [`managed`] owns
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
//! `ManagedClientTask` /
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
//! feature) wraps that in a synchronous owner backed by its own dedicated
//! current-thread runtime for callers that want managed multi-target
//! behavior without touching Tokio themselves.
//! //!
//! ```no_run //! A successful [`OpenOutcome`] carries either [`SessionStarted`] (with a live
//! use std::time::Duration; //! token) or [`NoTestCompleted`] (terminal, with no probe session). Lifecycle
//! events use these same payload types. Their [`NegotiationResult`] separates
//! typed [`AcceptedSessionParameters`] from the exact decoded peer `Params` and
//! accepted [`NegotiationChange`] records. Use `accepted.interval` and
//! `accepted.duration` for cadence and lifetime, `accepted.dscp` for the public
//! DSCP codepoint, and `peer_params` for wire diagnostics. Strict/Loose policy
//! compares the actual request and reply wire values before deriving accepted
//! semantics. Both adapters' `negotiation()` accessors return only a live
//! session's result; no-test completion leaves no current session.
//! //!
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig}; //! See `examples/` in the repository for a runnable example of each tier.
//! //!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> { #![forbid(unsafe_code)]
//! let config = ClientConfig {
//! server_addr: "127.0.0.1:2112".to_owned(),
//! duration: Some(Duration::from_secs(10)),
//! interval: Duration::from_secs(1),
//! ..ClientConfig::default()
//! };
//!
//! let (session, events) =
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
//!
//! while let Ok(event) = events.recv() {
//! match event {
//! ClientEvent::EchoReply { seq, rtt, .. } => {
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
//! }
//! ClientEvent::SessionClosed { .. } => break,
//! _ => {}
//! }
//! }
//!
//! let _outcome = session.join()?;
//! # Ok(())
//! # }
//! ```
//!
#![cfg_attr(
not(all(target_os = "linux", feature = "ancillary")),
forbid(unsafe_code)
)]
#![cfg_attr(all(target_os = "linux", feature = "ancillary"), deny(unsafe_code))]
#[cfg(feature = "tokio")]
mod async_client;
mod auth;
mod client; mod client;
mod config; mod config;
mod error; mod error;
mod event; mod event;
mod managed; pub mod managed;
mod metadata; mod metadata;
mod probe; mod probe;
mod receive; mod receive;
mod send;
mod session; mod session;
mod socket; mod socket;
mod socket_options; mod socket_options;
mod timing; mod timing;
#[cfg(feature = "tokio")]
pub use async_client::AsyncClient;
pub use auth::{Authentication, HmacKey};
pub use client::Client; pub use client::Client;
pub use config::{ pub use config::{
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT, AddressFamily, ClientConfig, NegotiationPolicy, OpenPolicy, RecvBudget, RunMode,
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH, SessionRequest, SocketConfig, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL,
MAX_UDP_PAYLOAD_LENGTH,
}; };
pub use error::{ClientError, EventSubscriptionError}; pub use error::ClientError;
pub use event::{ pub use event::{
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample, ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
ServerTiming, SignedDuration, WarningKind, RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
}; };
pub use managed::{ pub use send::{SendProbeError, SendReceipt};
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession, pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow,
};
pub use session::{NegotiatedParams, NegotiationRestriction};
pub use timing::ClientTimestamp; pub use timing::ClientTimestamp;
+175
View File
@@ -0,0 +1,175 @@
use std::{
thread::{self, JoinHandle},
time::Duration,
};
use thiserror::Error;
use tokio::runtime::{Builder, Runtime};
use super::{
ManagedClient, ManagedClientConfig, ManagedClientHandle, ManagedClientTask, ManagedConfigError,
ManagedEventSubscription, ManagedOutcome, ManagedSubscribeError, ManagedTargetConfig,
};
const BLOCKING_RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
/// Owns the runtime used exclusively by the blocking managed worker.
pub(super) struct WorkerRuntime {
runtime: Option<Runtime>,
}
impl WorkerRuntime {
pub(super) fn new(runtime: Runtime) -> Self {
Self {
runtime: Some(runtime),
}
}
fn block_on<F: std::future::Future>(&self, task: F) -> F::Output {
self.runtime
.as_ref()
.expect("worker runtime is available until shutdown")
.block_on(task)
}
/// Shut down residual runtime work without delaying managed completion indefinitely.
pub(super) fn shutdown(mut self) {
self.runtime
.take()
.expect("worker runtime is available until shutdown")
.shutdown_timeout(BLOCKING_RUNTIME_SHUTDOWN_TIMEOUT);
}
}
impl Drop for WorkerRuntime {
fn drop(&mut self) {
if let Some(runtime) = self.runtime.take() {
runtime.shutdown_background();
}
}
}
/// Failure to start a blocking managed client owner.
#[derive(Debug, Error)]
pub enum BlockingManagedStartError {
/// The managed task configuration was invalid.
#[error(transparent)]
Config(#[from] ManagedConfigError),
/// The managed event subscription could not be created before startup.
#[error(transparent)]
Subscribe(#[from] ManagedSubscribeError),
/// The dedicated Tokio runtime could not be created.
#[error("failed to create blocking managed Tokio runtime: {source}")]
Runtime {
#[source]
source: std::io::Error,
},
/// The dedicated worker thread could not be spawned.
#[error("failed to spawn blocking managed worker thread: {source}")]
ThreadSpawn {
#[source]
source: std::io::Error,
},
}
/// Failure to obtain completion from a blocking managed client owner.
#[derive(Debug, Error, Eq, PartialEq)]
pub enum BlockingManagedJoinError {
/// The dedicated managed worker thread panicked.
#[error("blocking managed worker thread panicked")]
WorkerPanicked,
}
/// Blocking completion owner for one unified managed Tokio task.
///
/// This owner runs exactly one [`ManagedClient`] task on one dedicated OS thread
/// with its own current-thread Tokio runtime. Dropping a live owner requests a
/// graceful stop and synchronously joins that worker, so drop may block while
/// bounded managed cleanup completes.
#[must_use = "dropping the blocking managed owner requests stop and joins its worker"]
pub struct BlockingManagedClient {
handle: ManagedClientHandle,
worker: Option<JoinHandle<ManagedOutcome>>,
}
impl BlockingManagedClient {
/// Validate configuration, create a dedicated runtime, and start its worker.
pub fn start(
config: ManagedClientConfig,
targets: Vec<ManagedTargetConfig>,
) -> Result<Self, BlockingManagedStartError> {
let (task, handle) = ManagedClient::task(config, targets)?;
Self::start_task(task, handle)
}
/// Validate configuration, subscribe to presentation events, and start a
/// dedicated managed worker.
///
/// The returned subscription exists before the managed task receives its
/// first poll, so it cannot miss startup events because of subscription
/// ordering.
pub fn start_with_subscription(
config: ManagedClientConfig,
targets: Vec<ManagedTargetConfig>,
) -> Result<(Self, ManagedEventSubscription), BlockingManagedStartError> {
let (task, handle) = ManagedClient::task(config, targets)?;
let subscription = handle.subscribe()?;
let owner = Self::start_task(task, handle)?;
Ok((owner, subscription))
}
fn start_task(
task: ManagedClientTask,
handle: ManagedClientHandle,
) -> Result<Self, BlockingManagedStartError> {
let runtime = Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|source| BlockingManagedStartError::Runtime { source })?;
let runtime = WorkerRuntime::new(runtime);
let worker = thread::Builder::new()
.name("irtt-managed".to_owned())
.spawn(move || {
let outcome = runtime.block_on(task);
runtime.shutdown();
outcome
})
.map_err(|source| BlockingManagedStartError::ThreadSpawn { source })?;
Ok(Self {
handle,
worker: Some(worker),
})
}
/// Return a cloned managed control and observation capability.
pub fn handle(&self) -> ManagedClientHandle {
self.handle.clone()
}
/// Wait synchronously for the managed task's authoritative outcome.
pub fn join(mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
self.join_worker()
}
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
self.worker
.take()
.expect("blocking managed worker is joined at most once")
.join()
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
}
}
impl Drop for BlockingManagedClient {
fn drop(&mut self) {
if self.worker.is_some() {
let _receipt = self.handle.stop();
let _ = self.join_worker();
}
}
}
@@ -1,52 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
/// Cloneable cooperative cancellation flag.
///
/// Managed sessions use this internally to ask their worker loop to stop. It is
/// exported for callers that want the same simple cancellation primitive in
/// code built around `irtt-client`.
#[derive(Debug, Clone, Default)]
pub struct CancellationToken {
cancelled: Arc<AtomicBool>,
}
impl CancellationToken {
/// Create a token in the non-cancelled state.
pub fn new() -> Self {
Self::default()
}
/// Mark the token as cancelled.
///
/// Cancellation is idempotent and is visible through all clones of the
/// token.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// Return whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancel_is_idempotent_and_visible_through_clones() {
let token = CancellationToken::new();
let clone = token.clone();
token.cancel();
token.cancel();
assert!(token.is_cancelled());
assert!(clone.is_cancelled());
}
}
-450
View File
@@ -1,450 +0,0 @@
use std::{
collections::{HashMap, VecDeque},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Condvar, Mutex, Weak,
},
};
use crate::{error::EventSubscriptionError, event::ClientEvent};
/// Configuration for one managed event subscriber.
///
/// Each subscriber has an independent bounded queue. The queue bounds memory
/// growth when event production is faster than that subscriber's consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubscriberConfig {
/// Maximum number of events buffered for this subscriber.
///
/// Must be greater than zero.
pub capacity: usize,
/// Policy applied when publishing to a full subscriber queue.
pub overflow: SubscriberOverflow,
}
impl Default for SubscriberConfig {
fn default() -> Self {
Self {
capacity: 1024,
overflow: SubscriberOverflow::DropNewest,
}
}
}
/// Overflow behavior for a bounded event subscription queue.
///
/// These policies are applied independently per subscriber when that
/// subscriber's queue is full.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubscriberOverflow {
/// Leave the existing queue unchanged and discard the newly published event.
DropNewest,
/// Remove the oldest queued event and enqueue the newly published event.
DropOldest,
/// Disconnect the subscriber and clear its queue.
Disconnect,
}
/// Publish/subscribe fan-out for [`ClientEvent`] values.
///
/// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber.
#[derive(Debug, Clone)]
pub struct EventHub {
inner: Arc<HubInner>,
}
#[derive(Debug)]
struct HubInner {
next_id: AtomicU64,
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner>>>,
}
/// Handle for receiving managed client events.
///
/// Each subscription has its own bounded queue configured by
/// [`SubscriberConfig`]. The configured [`SubscriberOverflow`] policy applies
/// when that queue is full. Dropping the handle unregisters the subscriber.
#[must_use = "dropping the subscription unregisters it"]
#[derive(Debug)]
pub struct EventSubscription {
id: u64,
hub: Weak<HubInner>,
inner: Arc<SubscriberInner>,
}
#[derive(Debug)]
struct SubscriberInner {
state: Mutex<SubscriberState>,
available: Condvar,
config: SubscriberConfig,
}
#[derive(Debug)]
struct SubscriberState {
queue: VecDeque<ClientEvent>,
connected: bool,
}
impl EventHub {
/// Create an empty event hub with no subscribers.
pub fn new() -> Self {
Self {
inner: Arc::new(HubInner {
next_id: AtomicU64::new(1),
subscribers: Mutex::new(HashMap::new()),
}),
}
}
/// Register a new subscriber with its own bounded queue.
///
/// Returns an error when `config.capacity` is zero.
pub fn subscribe(
&self,
config: SubscriberConfig,
) -> Result<EventSubscription, crate::ClientError> {
if config.capacity == 0 {
return Err(crate::ClientError::InvalidConfig {
reason: "subscriber capacity must be greater than zero".to_owned(),
});
}
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
let inner = Arc::new(SubscriberInner {
state: Mutex::new(SubscriberState {
queue: VecDeque::with_capacity(config.capacity),
connected: true,
}),
available: Condvar::new(),
config,
});
self.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.insert(id, inner.clone());
Ok(EventSubscription {
id,
hub: Arc::downgrade(&self.inner),
inner,
})
}
/// Publish an event to all currently connected subscribers.
///
/// Slow subscribers are handled according to their
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
/// subscriber to consume queued events.
pub fn publish(&self, event: ClientEvent) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.iter()
.map(|(id, subscriber)| (*id, subscriber.clone()))
.collect();
let mut disconnected = Vec::new();
for (id, subscriber) in subscribers {
// Events are cloned per subscriber; bounded queues keep slow
// consumers from causing unbounded memory growth.
if !subscriber.publish(event.clone()) {
disconnected.push(id);
}
}
if disconnected.is_empty() {
return;
}
let mut subscribers = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned");
for id in disconnected {
subscribers.remove(&id);
}
}
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.drain()
.map(|(_, subscriber)| subscriber)
.collect();
for subscriber in subscribers {
subscriber.disconnect();
}
}
#[cfg(test)]
fn subscriber_count(&self) -> usize {
self.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.len()
}
}
impl Default for EventHub {
fn default() -> Self {
Self::new()
}
}
impl EventSubscription {
/// Block until the next queued event is available.
///
/// If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained.
pub fn recv(&self) -> Result<ClientEvent, EventSubscriptionError> {
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
loop {
if let Some(event) = state.queue.pop_front() {
return Ok(event);
}
if !state.connected {
return Err(EventSubscriptionError::Disconnected);
}
state = self
.inner
.available
.wait(state)
.expect("subscriber mutex poisoned");
}
}
/// Try to receive one queued event without blocking.
///
/// Returns `Ok(None)` when the subscription is still connected but no event
/// is queued. If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained.
pub fn try_recv(&self) -> Result<Option<ClientEvent>, EventSubscriptionError> {
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
if let Some(event) = state.queue.pop_front() {
return Ok(Some(event));
}
if !state.connected {
return Err(EventSubscriptionError::Disconnected);
}
Ok(None)
}
}
impl Drop for EventSubscription {
fn drop(&mut self) {
self.inner.disconnect();
if let Some(hub) = self.hub.upgrade() {
hub.subscribers
.lock()
.expect("event hub mutex poisoned")
.remove(&self.id);
}
}
}
impl SubscriberInner {
fn publish(&self, event: ClientEvent) -> bool {
let mut state = self.state.lock().expect("subscriber mutex poisoned");
if !state.connected {
return false;
}
if state.queue.len() < self.config.capacity {
state.queue.push_back(event);
self.available.notify_one();
return true;
}
match self.config.overflow {
SubscriberOverflow::DropNewest => true,
SubscriberOverflow::DropOldest => {
state.queue.pop_front();
state.queue.push_back(event);
self.available.notify_one();
true
}
SubscriberOverflow::Disconnect => {
state.queue.clear();
state.connected = false;
self.available.notify_all();
false
}
}
}
fn disconnect(&self) {
let mut state = self.state.lock().expect("subscriber mutex poisoned");
state.connected = false;
self.available.notify_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ClientTimestamp, WarningKind};
fn event(n: usize) -> ClientEvent {
ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
message: format!("event-{n}"),
at: ClientTimestamp::now(),
}
}
fn event_message(event: ClientEvent) -> String {
match event {
ClientEvent::Warning { message, .. } => message,
other => panic!("unexpected event: {other:?}"),
}
}
fn assert_next(
sub: &EventSubscription,
expected: Result<Option<&str>, EventSubscriptionError>,
) {
match (sub.try_recv(), expected) {
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
(Ok(None), Ok(None)) => {}
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
}
#[test]
fn publishes_to_multiple_subscribers() {
let hub = EventHub::new();
let a = hub.subscribe(SubscriberConfig::default()).unwrap();
let b = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.publish(event(1));
assert_eq!(event_message(a.try_recv().unwrap().unwrap()), "event-1");
assert_eq!(event_message(b.try_recv().unwrap().unwrap()), "event-1");
}
#[test]
fn full_subscriber_overflow_policy_is_applied() {
let cases = [
(
SubscriberOverflow::DropNewest,
Ok(Some("event-1")),
Ok(None),
),
(
SubscriberOverflow::DropOldest,
Ok(Some("event-2")),
Ok(None),
),
(
SubscriberOverflow::Disconnect,
Err(EventSubscriptionError::Disconnected),
Err(EventSubscriptionError::Disconnected),
),
];
for (overflow, first_expected, second_expected) in cases {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_next(&sub, first_expected);
assert_next(&sub, second_expected);
}
}
#[test]
fn full_subscriber_does_not_prevent_other_delivery() {
let hub = EventHub::new();
let slow = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropNewest,
})
.unwrap();
let fast = hub
.subscribe(SubscriberConfig {
capacity: 4,
overflow: SubscriberOverflow::DropNewest,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(slow.try_recv().unwrap().unwrap()), "event-1");
assert_eq!(event_message(fast.try_recv().unwrap().unwrap()), "event-1");
assert_eq!(event_message(fast.try_recv().unwrap().unwrap()), "event-2");
}
#[test]
fn subscribing_after_events_does_not_replay() {
let hub = EventHub::new();
hub.publish(event(1));
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
assert!(sub.try_recv().unwrap().is_none());
}
#[test]
fn dropping_subscription_unregisters_from_hub() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
assert_eq!(hub.subscriber_count(), 1);
drop(sub);
assert_eq!(hub.subscriber_count(), 0);
}
#[test]
fn disconnect_all_wakes_blocking_receivers() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.disconnect_all();
assert_eq!(
sub.recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
}
#[test]
fn session_closed_event_can_be_queued() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.publish(ClientEvent::SessionClosed {
remote: "127.0.0.1:1".parse().unwrap(),
token: 1,
at: ClientTimestamp::now(),
});
assert!(matches!(
sub.recv().unwrap(),
ClientEvent::SessionClosed { token: 1, .. }
));
}
}
+19 -6
View File
@@ -1,7 +1,20 @@
mod cancellation; mod target_id;
mod hub;
mod runner;
pub use cancellation::CancellationToken; pub use target_id::TargetId;
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome}; #[cfg(feature = "tokio")]
mod blocking;
#[cfg(feature = "tokio")]
mod model;
#[cfg(feature = "tokio")]
mod task;
#[cfg(feature = "tokio")]
pub use blocking::*;
#[cfg(feature = "tokio")]
pub use model::*;
#[cfg(feature = "tokio")]
pub use task::*;
#[cfg(feature = "tokio")]
mod schedule;
+503
View File
@@ -0,0 +1,503 @@
use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
use thiserror::Error;
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
use super::TargetId;
/// Default capacity of the lossy managed event channel.
pub const DEFAULT_MANAGED_EVENT_CAPACITY: usize = 256;
/// Default capacity of the bounded managed control queue.
pub const DEFAULT_MANAGED_COMMAND_CAPACITY: usize = 64;
/// Default number of recent target outcomes retained in status and output.
pub const DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Default maximum number of simultaneously live target generations.
pub const DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
/// Default time retained for late replies after the final committed timeout.
pub const DEFAULT_MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
/// One globally allocated incarnation of a target within a managed task.
///
/// Generations increase globally across the task, not independently per ID.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TargetInstance {
pub id: TargetId,
pub generation: u64,
}
/// Authentication specification for a managed target.
///
/// Equality preserves the specification: inheritance and an explicit override
/// remain distinct even when they currently resolve to the same authentication.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum TargetAuth {
/// Use the shared client configuration's authentication.
#[default]
Inherit,
/// Replace shared authentication. `Unauthenticated` explicitly disables it.
Override(Authentication),
}
impl TargetAuth {
pub(crate) fn resolve(&self, shared: &Authentication) -> Authentication {
match self {
Self::Inherit => shared.clone(),
Self::Override(auth) => auth.clone(),
}
}
}
/// 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,
}
impl ManagedTargetConfig {
pub fn new(id: impl Into<TargetId>, server_addr: impl Into<String>) -> Self {
Self {
id: id.into(),
server_addr: server_addr.into(),
address_family: None,
auth: TargetAuth::Inherit,
}
}
}
/// Coordination mode for sends across active targets.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ManagedPacing {
#[default]
Staggered,
Burst,
}
/// Policy controlling top-level natural completion.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ManagedCompletionPolicy {
#[default]
FinishWhenQuiescent,
ExplicitStop,
}
/// Shared configuration for one managed task.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedClientConfig {
/// Reusable session/socket configuration shared across target generations.
/// Each target supplies its endpoint and resolves its family and authentication policies.
pub client: ClientConfig,
pub pacing: ManagedPacing,
pub completion: ManagedCompletionPolicy,
pub event_capacity: usize,
pub command_capacity: usize,
pub outcome_history_limit: usize,
pub max_live_target_generations: usize,
pub final_drain: Duration,
}
impl Default for ManagedClientConfig {
fn default() -> Self {
Self {
client: ClientConfig::default(),
pacing: ManagedPacing::default(),
completion: ManagedCompletionPolicy::default(),
event_capacity: DEFAULT_MANAGED_EVENT_CAPACITY,
command_capacity: DEFAULT_MANAGED_COMMAND_CAPACITY,
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
}
}
}
/// Durable top-level lifecycle.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedLifecycle {
NotStarted,
Running,
Stopping,
Completed,
Failed,
Abandoned,
}
/// Durable lifecycle for one target generation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetLifecycle {
Pending,
Connecting,
Opening,
Active,
Draining,
Closing,
Terminal,
}
/// Status for one target generation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetStatus {
pub target: TargetInstance,
pub desired: bool,
pub lifecycle: ManagedTargetLifecycle,
/// Authoritative completed accounting for this target generation.
///
/// Present if and only if `lifecycle` is [`ManagedTargetLifecycle::Terminal`].
/// This shares the terminal phase's outcome with [`ManagedEvent::TargetFinished`],
/// so terminal details remain durable even when presentation events are lost.
pub outcome: Option<Arc<ManagedTargetOutcome>>,
pub server_addr: Arc<str>,
pub remote: Option<SocketAddr>,
}
/// Authoritative immutable managed status snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedStatus {
pub lifecycle: ManagedLifecycle,
/// Whether the task durably observed an explicit stop request.
///
/// This remains false until the task observes the request latch. Natural
/// stopping and terminality alone do not imply a stop request was observed.
pub stop_requested: bool,
pub applied_command_sequence: u64,
pub desired_target_count: usize,
pub connecting_target_count: usize,
pub opening_target_count: usize,
pub active_target_count: usize,
pub draining_target_count: usize,
pub closing_target_count: usize,
pub terminal_target_count: usize,
pub total_target_outcomes: u64,
pub successful_target_outcomes: u64,
pub failed_target_outcomes: u64,
pub peer_closed_target_outcomes: u64,
pub discarded_target_outcomes: u64,
pub targets: Arc<[ManagedTargetStatus]>,
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
/// Authoritative final accounting, present if and only if `lifecycle` is
/// [`ManagedLifecycle::Completed`] or [`ManagedLifecycle::Failed`].
///
/// Shares the terminal task phase's outcome with [`ManagedEvent::Completed`]
/// or [`ManagedEvent::Failed`]. Abandoned deliberately has no outcome.
pub final_outcome: Option<Arc<ManagedOutcome>>,
}
/// Receiving half of authoritative durable latest-state observation.
///
/// The current snapshot is immediately readable. Updates may coalesce, and the
/// final snapshot remains available after the sender closes at task termination.
/// Use [`ManagedEventSubscription`] for lossy presentation events.
pub type ManagedStatusSubscription = tokio::sync::watch::Receiver<Arc<ManagedStatus>>;
/// Lossy presentation event emitted by the managed task.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedEvent {
Started,
TargetStateChanged {
target: TargetInstance,
lifecycle: ManagedTargetLifecycle,
},
Client {
target: TargetInstance,
event: ClientEvent,
},
TargetFinished {
outcome: Arc<ManagedTargetOutcome>,
},
Stopping,
Completed {
outcome: Arc<ManagedOutcome>,
},
Failed {
outcome: Arc<ManagedOutcome>,
},
Abandoned,
}
/// Receiving half of the lossy managed event stream.
pub type ManagedEventSubscription = tokio::sync::broadcast::Receiver<ManagedEvent>;
/// Immediate result of attempting to receive a managed presentation event.
pub type ManagedEventTryRecvError = tokio::sync::broadcast::error::TryRecvError;
/// Final authoritative outcome returned by the task.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedOutcome {
pub end_reason: ManagedEndReason,
pub applied_command_sequence: u64,
pub total_target_outcomes: u64,
pub successful_target_outcomes: u64,
pub failed_target_outcomes: u64,
pub peer_closed_target_outcomes: u64,
pub discarded_target_outcomes: u64,
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
}
/// Why the managed task ended.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedEndReason {
TargetsComplete,
StopRequested,
DriverFailed(ManagedDriverFailure),
}
/// Durable outcome for one target generation.
///
/// These counters are durable: unlike [`ManagedEvent`], which is a lossy
/// presentation stream, they account for everything the target observed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetOutcome {
pub target: TargetInstance,
pub server_addr: Arc<str>,
pub remote: Option<SocketAddr>,
pub end_reason: ManagedTargetEndReason,
/// Probes the underlying client reported as sent.
pub packets_sent: u64,
/// Unique echo replies that arrived in order and before their probe timed
/// out.
///
/// A unique reply that arrives behind an already-received sequence, or
/// after its own probe timed out, is counted by `late` instead.
pub replies_received: u64,
/// Replies for a sequence that had already been answered.
pub duplicates: u64,
/// Late replies: those arriving behind an already-received sequence
/// (reordering) or after their own probe timed out.
///
/// This counts every late reply, whether or not the client could still
/// match it to retained send state.
pub late: u64,
/// Warning events observed for this target.
pub warning_events: u64,
pub cleanup_failure: Option<ManagedTargetFailure>,
}
/// Why one target generation ended.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedTargetEndReason {
TestComplete,
NoTestComplete,
PeerClosed,
Removed,
Replaced,
Stopped,
Failed(ManagedTargetFailure),
}
/// Phase in which a target-local failure occurred.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetFailurePhase {
Connecting,
Opening,
Sending,
Receiving,
Timing,
Closing,
}
impl fmt::Display for ManagedTargetFailurePhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Connecting => "connecting",
Self::Opening => "opening",
Self::Sending => "sending",
Self::Receiving => "receiving",
Self::Timing => "timing",
Self::Closing => "closing",
};
f.write_str(text)
}
}
/// Stable category for a target-local failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedTargetFailureKind {
Resolve,
Socket,
SocketOption,
Protocol,
Timeout,
Configuration,
ResourceExhausted,
InvalidState,
/// A failure that deliberately has no more specific category.
///
/// This is a classification decision, not a fallback: every current
/// [`ClientError`] variant is classified explicitly, so nothing reaches
/// this category by default.
Other,
}
impl fmt::Display for ManagedTargetFailureKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Resolve => "resolve",
Self::Socket => "socket",
Self::SocketOption => "socket option",
Self::Protocol => "protocol",
Self::Timeout => "timeout",
Self::Configuration => "configuration",
Self::ResourceExhausted => "resource exhausted",
Self::InvalidState => "invalid state",
Self::Other => "other",
};
f.write_str(text)
}
}
/// Durable target-local failure details.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedTargetFailure {
pub phase: ManagedTargetFailurePhase,
pub kind: ManagedTargetFailureKind,
pub message: Arc<str>,
}
/// Configuration failure detected before any runtime work begins.
#[derive(Debug, Error)]
pub enum ManagedConfigError {
#[error("FinishWhenQuiescent requires at least one initial target")]
EmptyInitialTargets,
#[error("duplicate managed target id {id}")]
DuplicateTargetId { id: TargetId },
#[error("configured {configured} targets exceeds live-generation limit {limit}")]
TooManyTargets { configured: usize, limit: usize },
#[error("managed event capacity must be greater than zero")]
ZeroEventCapacity,
#[error("managed event capacity {configured} exceeds Tokio's maximum {maximum}")]
EventCapacityTooLarge { configured: usize, maximum: usize },
#[error("managed command capacity must be greater than zero")]
ZeroCommandCapacity,
#[error("managed command capacity {configured} exceeds Tokio's maximum {maximum}")]
CommandCapacityTooLarge { configured: usize, maximum: usize },
#[error("managed live-generation limit must be greater than zero")]
ZeroLiveGenerationLimit,
#[error("managed final drain {duration:?} cannot be scheduled from the current instant")]
UnschedulableFinalDrain { duration: Duration },
#[error("invalid managed target {id}: {source}")]
InvalidTarget {
id: TargetId,
#[source]
source: ClientError,
},
#[error("managed target generation space is exhausted")]
GenerationExhausted,
}
/// Immediate failure to submit a managed target update.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ManagedCommandError {
#[error("managed target updates are no longer accepted")]
Stopping,
#[error("managed target update contains {configured} targets but the limit is {limit}")]
TooManyTargets { configured: usize, limit: usize },
#[error("managed command queue is full")]
QueueFull,
#[error("managed command receiver is closed")]
DriverClosed,
}
/// Failure while applying an accepted managed target update.
#[derive(Debug, Error)]
pub enum ManagedCommandApplyError {
#[error("managed target updates are no longer accepted")]
Stopping,
#[error("duplicate target id {id}")]
DuplicateTargetId { id: TargetId },
#[error("invalid configuration for target {id}: {source}")]
InvalidTarget {
id: TargetId,
#[source]
source: ClientError,
},
#[error("target update requires {required} live generations but limit is {limit}")]
LiveGenerationLimitExceeded { required: usize, limit: usize },
#[error("managed target generation space is exhausted")]
GenerationExhausted,
#[error("managed command sequence is exhausted")]
CommandSequenceExhausted,
#[error("managed task ended before command acknowledgement")]
AcknowledgementDisconnected,
#[error("managed driver failed before command application: {0}")]
DriverFailed(ManagedDriverFailure),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedCommandAcknowledgement {
pub sequence: u64,
pub status: Arc<ManagedStatus>,
}
/// Failure to create a new event subscription.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ManagedSubscribeError {
#[error("managed event stream is closed")]
Closed,
}
/// Whole-driver failure.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ManagedDriverFailure {
#[error("ManagedClientTask requires a current Tokio runtime")]
NoTokioRuntime,
#[error("managed driver exhausted resources for {operation}")]
ResourceExhausted { operation: &'static str },
#[error("managed driver invariant failed: {message}")]
Internal { message: Arc<str> },
}
/// Map a [`ClientError`] onto its durable managed failure classification.
///
/// The match is deliberately exhaustive over every [`ClientError`] variant. A
/// new variant must fail to compile here until its real classification is
/// chosen, rather than silently degrading to
/// [`ManagedTargetFailureKind::Other`].
pub(crate) fn classify_client_error(
phase: ManagedTargetFailurePhase,
error: &ClientError,
) -> ManagedTargetFailure {
let kind = match error {
ClientError::Resolve { .. } => ManagedTargetFailureKind::Resolve,
ClientError::Socket(_) => ManagedTargetFailureKind::Socket,
ClientError::SocketOption { .. } | ClientError::ReadTimeoutRestore { .. } => {
ManagedTargetFailureKind::SocketOption
}
ClientError::Protocol(_)
| ClientError::ProtocolVersionMismatch { .. }
| ClientError::ZeroToken
| ClientError::UnexpectedNoTestReply
| ClientError::NonZeroNoTestToken { .. }
| ClientError::ServerRejected
| ClientError::NegotiationRejected { .. } => ManagedTargetFailureKind::Protocol,
ClientError::OpenTimeout => ManagedTargetFailureKind::Timeout,
ClientError::InvalidConfig { .. }
| ClientError::OpenTimeoutTooSmall { .. }
| ClientError::NoOpenTimeouts => ManagedTargetFailureKind::Configuration,
ClientError::AllocationFailed { .. }
| ClientError::CounterOverflow { .. }
| ClientError::DurationOverflow
| ClientError::PendingLimitExceeded { .. } => ManagedTargetFailureKind::ResourceExhausted,
ClientError::DatagramLengthMismatch { .. } => ManagedTargetFailureKind::Socket,
#[cfg(feature = "tokio")]
ClientError::NoTokioRuntime => ManagedTargetFailureKind::InvalidState,
ClientError::NotOpen
| ClientError::AlreadyOpen
| ClientError::AlreadyCompleted
| ClientError::AlreadyClosed
| ClientError::StalePreparedProbe { .. }
| ClientError::PendingSequenceCollision { .. } => ManagedTargetFailureKind::InvalidState,
};
ManagedTargetFailure {
phase,
kind,
message: Arc::from(error.to_string()),
}
}
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more