Compare commits

...
51 Commits
Author SHA1 Message Date
Nils Andreas Svee 38e912632d chore(release): irtt-client 0.6.0, irtt-stats 0.5.2, irtt-rs 0.8.0
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 1m42s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 1m44s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Publish irtt-server image / publish (push) Successful in 1m8s
Semver / cargo-semver-checks (push) Successful in 54s
Rust CI / cargo fmt (push) Successful in 18s
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 1m11s
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 clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 30s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Successful in 1m7s
2026-10-07 14:26:50 +07:00
Nils Andreas Svee eaa37d7568 Merge branch 'feat/refactor-tui-history' 2026-10-07 14:25:49 +07:00
Nils Andreas Svee da7472e10a feat(app): add address-family selection and dual-stack targets 2026-10-07 14:17:11 +07:00
Nils Andreas Svee 9e6c10c083 refactor(tui): compact graph history and retain 500k samples 2026-10-07 12:41:49 +07:00
Nils Andreas Svee 988a360fef Merge branch 'feat/reconnect'
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo doc (push) Successful in 29s
Supply chain / actionlint (push) Successful in 3s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / cargo-deny advisories (push) Successful in 5s
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m10s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Netem Interop / netem interop (push) Successful in 1m13s
2026-10-06 17:59:41 +07:00
Nils Andreas Svee c28e63e67a feat(tui): enable continuous target re-provisioning with generation tracking
Allow targets to automatically restart after completing or failing, and ensure distinct state and statistics across successive target generations.
2026-10-06 17:39:55 +07:00
Nils Andreas Svee c0a4f7a6db Merge branch 'refactor/consolidate-proto-tests'
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / actionlint (push) Successful in 3s
Supply chain / cargo-deny advisories (push) Successful in 9s
Rust CI / cargo fmt (push) Successful in 18s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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 clippy (push) Successful in 31s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Netem Interop / netem interop (push) Successful in 1m9s
2026-10-06 14:56:06 +07:00
Nils Andreas Svee b98d1001de perf(tui): optimize graph rendering and increase UI responsiveness
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Netem Interop / netem interop (push) Successful in 1m5s
Efficiently sample data within the viewport for graph series and reduce the rendering interval for a smoother user experience.
2026-10-06 14:39:05 +07:00
Nils Andreas Svee 8e4424764d feat(tui): interpolate graph series at viewport boundaries
Ensure graph lines extend smoothly to the edges of the visible viewport, preventing abrupt truncations of data series.
2026-10-06 13:20:35 +07:00
Nils Andreas Svee 1e61f4168d refactor(tui): remove superfluous name field from chart series 2026-10-06 12:39:43 +07:00
Nils Andreas Svee 22cead20dc feat(tui): display wall-clock timestamps on graph x-axis
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 18s
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 1m13s
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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 clippy (push) Successful in 34s
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
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
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 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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 18s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 clippy (push) Successful in 32s
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
Rust CI / cargo doc (push) Successful in 27s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 17s
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 1m14s
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 clippy (push) Successful in 34s
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
Rust CI / cargo doc (push) Successful in 31s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 17s
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 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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
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 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
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Successful in 1m5s
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 28s
Supply chain / cargo-deny bans licenses sources (pull_request) Successful in 5s
Supply chain / actionlint (pull_request) Successful in 2s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (pull_request) Successful in 12s
Supply chain / cargo-deny advisories (pull_request) Successful in 6s
Netem Interop / netem interop (pull_request) Successful in 1m2s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 18s
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 1m13s
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 clippy (push) Successful in 33s
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
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / actionlint (push) Successful in 2s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Rust CI / cargo fmt (push) Successful in 16s
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 1m13s
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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 18s
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 1m15s
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
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
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Successful in 1m6s
Fuzz Smoke / cargo fuzz smoke (pull_request) Failing after 28s
Supply chain / actionlint (pull_request) Successful in 2s
Supply chain / cargo-deny bans licenses sources (pull_request) Successful in 5s
Rust CI / cargo clippy (push) Successful in 31s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (pull_request) Successful in 13s
Supply chain / cargo-deny advisories (pull_request) Successful in 6s
Netem Interop / netem interop (pull_request) Successful in 1m12s
Rust CI / cargo fmt (push) Successful in 14s
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 1m13s
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 clippy (push) Successful in 31s
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
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 10s
Supply chain / cargo-deny advisories (push) Successful in 10s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
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 clippy (push) Successful in 32s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 26s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
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 clippy (push) Successful in 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
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 clippy (push) Successful in 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo doc (push) Successful in 24s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 11s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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 clippy (push) Successful in 32s
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
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 10s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
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 clippy (push) Successful in 37s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Rust CI / cargo fmt (push) Successful in 19s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m16s
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 clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 22s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Rust CI / cargo fmt (push) Successful in 16s
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 1m13s
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 clippy (push) Successful in 31s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 15s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 28s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m10s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 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 clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 25s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 17s
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
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
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
107 changed files with 8974 additions and 7964 deletions

No files matched your search

+6 -15
View File
@@ -3,8 +3,7 @@
# separate terms of service, privacy policy, and support
# documentation.
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
# in addition to a PR check which fails if new vulnerabilities are introduced.
# Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
#
# For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/
@@ -20,6 +19,7 @@ on:
- cron: "45 8 * * 1"
push:
branches: ["main"]
workflow_dispatch:
permissions:
# Require writing security events to upload SARIF file to security tab
@@ -29,21 +29,12 @@ permissions:
actions: read
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:
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@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
-r
--skip-git
./
scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
-r
--skip-git
./
+92
View File
@@ -34,6 +34,37 @@ release. The format is loosely based on
## irtt-client
### 0.6.0
#### Added
- Added `ManagedClientHandle::subscribe_status()` and `ManagedStatusSubscription` for observing durable lifecycle and completion state independently of the lossy event stream. The current snapshot is immediately readable, change notifications track subsequent updates, and terminal target status includes its completed outcome.
- Managed targets can override the shared address-family policy while retaining the original hostname for resolution on each new generation.
#### Changed
- `ClientConfig` is now reusable across endpoints: `Client::connect` and `AsyncClient::connect` take the endpoint separately, with configuration grouped into `socket`, `request` (`SessionRequest`), and `open` (`OpenPolicy`). `AddressFamily` replaces the separate socket family flags, and blocking receive timeouts are configured through `Client::set_recv_timeout`.
- Explicit `Authentication`, `HmacKey`, and managed `TargetAuth` replace `ClientConfig.hmac_key` and `ClientAuthConfig`. Key bytes are shared across clones and redacted from debug output. Switching between inherited and explicit authentication or address-family settings starts a new target generation, even when the effective settings are equal.
- `NegotiationResult` replaces `NegotiatedParams`, separating typed `AcceptedSessionParameters` from exact peer-returned wire parameters and accepted `NegotiationChange` records (formerly `NegotiationRestriction`). Open outcomes and lifecycle events now use tuple variants carrying `SessionStarted` or `NoTestCompleted` payloads.
- Low-level clients now leave probe cadence and run duration entirely to the caller. `next_send_deadline` and `is_run_complete` were removed; managed clients continue to provide scheduling and finite-run completion. Low-level callers can inspect `negotiation()`, `has_pending_probes()`, and `next_probe_timeout_deadline()` to drive their own event loop.
- Probe sends now return `Result<SendReceipt, SendProbeError>`, distinguishing failures before socket acceptance from failures after a probe was committed. Receipts convert to `ClientEvent::EchoSent`; its `scheduled_at` and `timer_error` fields are now optional and absent for low-level sends.
- Stop receipts resolve when the stop request is durably observed or the task becomes terminal; callers must still await task completion to observe finished cleanup.
#### Fixed
- Finite managed runs now reach their negotiated end under socket backpressure, even when no pending probe timeout remains to wake the driver. Staggered pacing no longer repeatedly wakes idle or draining targets that have no send deadline.
- Blocking receive operations now return lifecycle errors immediately when no session is open, instead of blocking for a datagram or returning empty success after a timeout.
#### Performance
- Managed drain-deadline lookup no longer scans retained timed-out probes, keeping lookup work bounded as retained probe state grows.
#### Compatibility
- The configuration, authentication, negotiation, lifecycle-event, and send APIs above are breaking changes without compatibility aliases. Low-level callers must enforce their own cadence and duration or use a managed client.
- `AcceptedSessionParameters::dscp` is a six-bit codepoint; `NegotiationResult::peer_params.dscp` remains the raw traffic-class byte. Accepted durations and intervals use Rust duration types rather than wire nanosecond integers.
- Tokio remains optional, and the default client build remains runtime-free. These API changes do not introduce a wire-protocol migration.
### 0.5.3
#### Added
@@ -119,6 +150,29 @@ release. The format is loosely based on
## irtt-stats
### 0.5.2
#### Changed
- `TimeStats` is now re-exported from `measurement-stats`, retaining the existing `irtt_stats::TimeStats` import path, fields, and methods.
- Timer-error statistics now omit sends without scheduling metadata instead of recording an artificial measurement.
#### Fixed
- Time-based rolling windows now expire backdated events correctly, including expired events inside retained arrival history. Older timestamps cannot move the expiry anchor backwards or restore expired history.
- Rolling upstream and downstream loss now use the server receive-count increase over the retained packet-event interval instead of comparing window counts against a cumulative server count.
- Rolling directional-loss estimates are unavailable when counter observations are missing, stale, discontinuous, or separated by an interior gap in the time window, rather than reporting misleading values.
#### Performance
- Indexed rolling-window expiry avoids full-window scans on insertion and reclaims expired entries without accumulating tombstones.
#### Compatibility
- This release consumes `irtt-client` 0.6 events; applications using both crates must upgrade them together.
- Unavailable rolling directional-loss packet estimates are `None`; their percentage fields remain `0.0`. Valid signed estimates remain possible, and raw server receive counts retain their cumulative meaning.
- Time-based rolling windows expire when new events arrive, retain events exactly at the cutoff, and have no event-count cap. Use `rolling_count` when event storage needs a fixed bound.
### 0.5.1
#### Added
@@ -138,6 +192,44 @@ release. The format is loosely based on
## 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
Generated
+170 -29
View File
@@ -17,6 +17,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -199,6 +208,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -211,6 +230,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -292,6 +322,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -317,6 +353,7 @@ dependencies = [
"crossterm_winapi",
"derive_more",
"document-features",
"futures-core",
"mio",
"parking_lot",
"rustix",
@@ -376,9 +413,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
@@ -496,9 +533,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
@@ -552,6 +589,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "finl_unicode"
version = "1.5.0"
@@ -685,6 +728,30 @@ dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -715,7 +782,7 @@ dependencies = [
[[package]]
name = "irtt-client"
version = "0.5.3"
version = "0.6.0"
dependencies = [
"irtt-proto",
"irtt-server",
@@ -748,15 +815,18 @@ dependencies = [
[[package]]
name = "irtt-rs"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"chrono",
"clap",
"crossterm",
"ctrlc",
"futures-core",
"irtt-client",
"irtt-proto",
"irtt-server",
"irtt-stats",
"nix 0.31.3",
"ratatui",
"tokio",
]
@@ -775,7 +845,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"irtt-client",
"measurement-stats",
@@ -833,15 +903,15 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libm"
@@ -896,12 +966,12 @@ dependencies = [
[[package]]
name = "mac_address"
version = "1.1.8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a"
dependencies = [
"nix 0.29.0",
"winapi",
"nix 0.30.1",
"windows-sys",
]
[[package]]
@@ -949,9 +1019,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.3"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
@@ -964,6 +1034,18 @@ name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
@@ -1032,9 +1114,9 @@ dependencies = [
[[package]]
name = "objc2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -1230,9 +1312,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "powerfmt"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
@@ -1589,6 +1671,12 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1872,9 +1960,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"libc",
"mio",
@@ -1950,9 +2038,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"atomic",
"getrandom 0.4.3",
@@ -2138,12 +2226,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2161,18 +2302,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.59"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
version = "0.8.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -18,9 +18,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
[workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
irtt-client = { version = "0.6.0", path = "crates/irtt-client" }
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" }
[profile.release]
opt-level = "z"
+1 -1
View File
@@ -10,7 +10,7 @@
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
COPY --from=xx / /
+11 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-rs"
version = "0.7.1"
version = "0.8.0"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
@@ -42,24 +42,29 @@ doc = false
[features]
default = ["client", "server", "tui"]
client = ["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
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:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
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", optional = true }
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.1", default-features = false, features = ["rt", "time"], 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.1", default-features = false, features = ["rt", "sync"] }
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"
+2 -1
View File
@@ -92,7 +92,7 @@ impl ClientArgs {
///
/// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned());
}
@@ -105,6 +105,7 @@ impl ClientArgs {
stdin_controlled: self.targets_stdin,
},
)
.await
}
pub fn is_continuous(&self) -> bool {
+225 -434
View File
@@ -5,10 +5,12 @@ use std::{
};
use irtt_client::{
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
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};
@@ -94,7 +96,7 @@ impl OutputConfig {
&self,
event: &ClientEvent,
target: Option<&str>,
stats: Option<&EventRenderStats>,
stats: Option<&EventStatsUpdate>,
) -> Option<String> {
let row = OutputRow::from_event(event);
if self.default_table_rows && row.is_default_table_hidden() {
@@ -133,10 +135,12 @@ impl OutputConfig {
}
writeln!(out).unwrap();
writeln!(out, "Aliases:").unwrap();
writeln!(out, " receive_delay, receive_delay_us").unwrap();
writeln!(out, " send_delay, send_delay_us").unwrap();
writeln!(out, " server_processing").unwrap();
writeln!(out, " server_received_count, server_received_window").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!(
@@ -149,45 +153,9 @@ impl OutputConfig {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EventRenderStats {
pub contributed_sample: bool,
pub ipdv_pairs: Vec<IpdvPair>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IpdvPair {
pub previous_seq: u32,
pub current_seq: u32,
pub rtt_ipdv: Duration,
pub send_ipdv: Option<Duration>,
pub receive_ipdv: Option<Duration>,
}
impl From<irtt_stats::EventStatsUpdate> for EventRenderStats {
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
Self {
contributed_sample: value.contributed_sample,
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
}
}
}
impl From<irtt_stats::IpdvPairUpdate> for IpdvPair {
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,
}
}
}
#[derive(Debug, Clone, Copy)]
struct RenderContext<'a> {
stats: Option<&'a EventRenderStats>,
stats: Option<&'a EventStatsUpdate>,
target: Option<&'a str>,
verbose: bool,
}
@@ -234,22 +202,22 @@ enum OutputRow {
impl OutputRow {
fn from_event(event: &ClientEvent) -> Self {
match event {
ClientEvent::SessionStarted {
ClientEvent::SessionStarted(irtt_client::SessionStarted {
remote,
token,
negotiated,
negotiation: negotiated,
at,
} => Self::SessionStarted(LifecycleRow::new(
}) => Self::SessionStarted(LifecycleRow::new(
*remote,
Some(*token),
negotiated,
at.wall,
)),
ClientEvent::NoTestCompleted {
ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted {
remote,
negotiated,
negotiation: negotiated,
at,
} => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
}) => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
remote: *remote,
token: *token,
@@ -368,6 +336,106 @@ impl OutputRow {
_ => 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)]
@@ -384,16 +452,19 @@ impl LifecycleRow {
fn new(
remote: SocketAddr,
token: Option<u64>,
negotiated: &NegotiatedParams,
negotiated: &NegotiationResult,
event_wall: SystemTime,
) -> Self {
Self {
remote,
token,
event_wall,
duration_ns: i128::from(negotiated.params.duration_ns),
interval_ns: i128::from(negotiated.params.interval_ns),
payload_length: i128::from(negotiated.params.length),
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),
}
}
}
@@ -412,274 +483,91 @@ struct ReplyRow {
packet_meta: PacketMeta,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Column {
Target,
Event,
Seq,
Remote,
Token,
Rtt,
RttUs,
RawRttUs,
EffectiveRttUs,
AdjustedRttUs,
ReceiveDelay,
ReceiveDelayUs,
SendDelay,
SendDelayUs,
Ipdv,
IpdvUs,
ServerProcessing,
ServerProcessingUs,
Bytes,
SendCallUs,
TimerErrorUs,
HighestSeen,
ServerReceivedCount,
ServerReceivedWindow,
Dscp,
Ecn,
TrafficClass,
KernelRxNs,
WarningKind,
Message,
EventWallNs,
ClientSendWallNs,
ClientReceiveWallNs,
DurationNs,
IntervalNs,
PayloadLength,
ServerReceiveWallNs,
ServerReceiveMonoNs,
ServerSendWallNs,
ServerSendMonoNs,
ServerMidpointWallNs,
ServerMidpointMonoNs,
// 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 };
}
const ALL_COLUMNS: &[Column] = &[
Column::Target,
Column::Event,
Column::Seq,
Column::Remote,
Column::Token,
Column::Rtt,
Column::RttUs,
Column::RawRttUs,
Column::EffectiveRttUs,
Column::AdjustedRttUs,
Column::ReceiveDelay,
Column::ReceiveDelayUs,
Column::SendDelay,
Column::SendDelayUs,
Column::Ipdv,
Column::IpdvUs,
Column::ServerProcessing,
Column::ServerProcessingUs,
Column::Bytes,
Column::SendCallUs,
Column::TimerErrorUs,
Column::HighestSeen,
Column::ServerReceivedCount,
Column::ServerReceivedWindow,
Column::Dscp,
Column::Ecn,
Column::TrafficClass,
Column::KernelRxNs,
Column::WarningKind,
Column::Message,
Column::EventWallNs,
Column::ClientSendWallNs,
Column::ClientReceiveWallNs,
Column::DurationNs,
Column::IntervalNs,
Column::PayloadLength,
Column::ServerReceiveWallNs,
Column::ServerReceiveMonoNs,
Column::ServerSendWallNs,
Column::ServerSendMonoNs,
Column::ServerMidpointWallNs,
Column::ServerMidpointMonoNs,
];
impl Column {
fn name(self) -> &'static str {
match self {
Self::Target => "target",
Self::Event => "event",
Self::Seq => "seq",
Self::Remote => "remote",
Self::Token => "token",
Self::Rtt => "rtt",
Self::RttUs => "rtt_us",
Self::RawRttUs => "raw_rtt_us",
Self::EffectiveRttUs => "effective_rtt_us",
Self::AdjustedRttUs => "adjusted_rtt_us",
Self::ReceiveDelay => "rd",
Self::ReceiveDelayUs => "rd_us",
Self::SendDelay => "sd",
Self::SendDelayUs => "sd_us",
Self::Ipdv => "ipdv",
Self::IpdvUs => "ipdv_us",
Self::ServerProcessing => "proc",
Self::ServerProcessingUs => "server_processing_us",
Self::Bytes => "bytes",
Self::SendCallUs => "send_call_us",
Self::TimerErrorUs => "timer_error_us",
Self::HighestSeen => "highest_seen",
Self::ServerReceivedCount => "server_received",
Self::ServerReceivedWindow => "server_window",
Self::Dscp => "dscp",
Self::Ecn => "ecn",
Self::TrafficClass => "traffic_class",
Self::KernelRxNs => "kernel_rx_ns",
Self::WarningKind => "warning_kind",
Self::Message => "message",
Self::EventWallNs => "event_wall_ns",
Self::ClientSendWallNs => "client_send_wall_ns",
Self::ClientReceiveWallNs => "client_receive_wall_ns",
Self::DurationNs => "duration_ns",
Self::IntervalNs => "interval_ns",
Self::PayloadLength => "payload_length",
Self::ServerReceiveWallNs => "server_receive_wall_ns",
Self::ServerReceiveMonoNs => "server_receive_mono_ns",
Self::ServerSendWallNs => "server_send_wall_ns",
Self::ServerSendMonoNs => "server_send_mono_ns",
Self::ServerMidpointWallNs => "server_midpoint_wall_ns",
Self::ServerMidpointMonoNs => "server_midpoint_mono_ns",
}
}
fn parse(input: &str) -> Option<Self> {
Some(match input {
"target" => Self::Target,
"event" => Self::Event,
"seq" => Self::Seq,
"remote" => Self::Remote,
"token" => Self::Token,
"rtt" => Self::Rtt,
"rtt_us" => Self::RttUs,
"raw_rtt_us" => Self::RawRttUs,
"effective_rtt_us" => Self::EffectiveRttUs,
"adjusted_rtt_us" => Self::AdjustedRttUs,
"rd" | "receive_delay" => Self::ReceiveDelay,
"rd_us" | "receive_delay_us" => Self::ReceiveDelayUs,
"sd" | "send_delay" => Self::SendDelay,
"sd_us" | "send_delay_us" => Self::SendDelayUs,
"ipdv" => Self::Ipdv,
"ipdv_us" => Self::IpdvUs,
"proc" | "server_processing" => Self::ServerProcessing,
"server_processing_us" => Self::ServerProcessingUs,
"bytes" => Self::Bytes,
"send_call_us" => Self::SendCallUs,
"timer_error_us" => Self::TimerErrorUs,
"highest_seen" => Self::HighestSeen,
"server_received" | "server_received_count" => Self::ServerReceivedCount,
"server_window" | "server_received_window" => Self::ServerReceivedWindow,
"dscp" => Self::Dscp,
"ecn" => Self::Ecn,
"traffic_class" => Self::TrafficClass,
"kernel_rx_ns" => Self::KernelRxNs,
"warning_kind" => Self::WarningKind,
"message" => Self::Message,
"event_wall_ns" => Self::EventWallNs,
"client_send_wall_ns" => Self::ClientSendWallNs,
"client_receive_wall_ns" => Self::ClientReceiveWallNs,
"duration_ns" => Self::DurationNs,
"interval_ns" => Self::IntervalNs,
"payload_length" => Self::PayloadLength,
"server_receive_wall_ns" => Self::ServerReceiveWallNs,
"server_receive_mono_ns" => Self::ServerReceiveMonoNs,
"server_send_wall_ns" => Self::ServerSendWallNs,
"server_send_mono_ns" => Self::ServerSendMonoNs,
"server_midpoint_wall_ns" => Self::ServerMidpointWallNs,
"server_midpoint_mono_ns" => Self::ServerMidpointMonoNs,
_ => return None,
})
}
fn description(self) -> &'static str {
match self {
Self::Target => "logical CLI target label",
Self::Event => "event kind",
Self::Seq => "probe sequence number",
Self::Remote => "remote socket address",
Self::Token => "session token as hexadecimal",
Self::Rtt => "human-readable effective RTT",
Self::RttUs => "effective RTT in signed microseconds",
Self::RawRttUs => "raw client send-to-receive RTT in microseconds",
Self::EffectiveRttUs => "effective RTT in signed microseconds",
Self::AdjustedRttUs => "adjusted RTT in signed microseconds",
Self::ReceiveDelay => "human-readable server-to-client delay",
Self::ReceiveDelayUs => "server-to-client delay in signed microseconds",
Self::SendDelay => "human-readable client-to-server delay",
Self::SendDelayUs => "client-to-server delay in signed microseconds",
Self::Ipdv => "human-readable round-trip IPDV for adjacent samples",
Self::IpdvUs => "round-trip IPDV in microseconds",
Self::ServerProcessing => "human-readable server processing time",
Self::ServerProcessingUs => "server processing time in microseconds",
Self::Bytes => "packet bytes for packet events",
Self::SendCallUs => "send system call duration in microseconds",
Self::TimerErrorUs => "scheduled-vs-actual send timer error in microseconds",
Self::HighestSeen => "highest sequence seen when a late reply arrived",
Self::ServerReceivedCount => "server-reported received packet count",
Self::ServerReceivedWindow => "server-reported received window as hexadecimal",
Self::Dscp => "received packet DSCP codepoint",
Self::Ecn => "received packet ECN bits",
Self::TrafficClass => "received packet traffic class byte",
Self::KernelRxNs => "kernel receive timestamp as Unix nanoseconds",
Self::WarningKind => "warning classifier",
Self::Message => "warning or lifecycle message",
Self::EventWallNs => "event wall timestamp as Unix nanoseconds",
Self::ClientSendWallNs => "client send wall timestamp as Unix nanoseconds",
Self::ClientReceiveWallNs => "client receive wall timestamp as Unix nanoseconds",
Self::DurationNs => "negotiated test duration in nanoseconds",
Self::IntervalNs => "negotiated probe interval in nanoseconds",
Self::PayloadLength => "negotiated payload length",
Self::ServerReceiveWallNs => "server receive wall timestamp in nanoseconds",
Self::ServerReceiveMonoNs => "server receive monotonic timestamp in nanoseconds",
Self::ServerSendWallNs => "server send wall timestamp in nanoseconds",
Self::ServerSendMonoNs => "server send monotonic timestamp in nanoseconds",
Self::ServerMidpointWallNs => "server midpoint wall timestamp in nanoseconds",
Self::ServerMidpointMonoNs => "server midpoint monotonic timestamp in nanoseconds",
}
}
fn table_width(self) -> usize {
match self {
Self::Target => 18,
Self::Event => 17,
Self::Seq => 6,
Self::Remote => 21,
Self::Token => 18,
Self::Rtt
| Self::ReceiveDelay
| Self::SendDelay
| Self::Ipdv
| Self::ServerProcessing => 9,
Self::Message => 24,
Self::WarningKind => 28,
Self::ServerReceivedWindow => 13,
Self::ServerReceivedCount | Self::HighestSeen => 15,
Self::Dscp | Self::Ecn => 4,
Self::TrafficClass => 13,
_ => self.name().len().max(10),
}
}
fn align_right(self) -> bool {
!matches!(
self,
Self::Target
| Self::Event
| Self::Remote
| Self::Token
| Self::Message
| Self::WarningKind
| Self::ServerReceivedWindow
)
}
// 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)]
@@ -887,9 +775,11 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
match column {
Column::Target => context.target.map(CellValue::text),
Column::Event => Some(CellValue::text(row.event_name())),
Column::Seq => row_seq(row).map(|seq| CellValue::Unsigned(u128::from(seq))),
Column::Remote => row_remote(row).map(|remote| CellValue::text(remote.to_string())),
Column::Token => row_token(row).map(|token| CellValue::Hex(u128::from(token))),
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
@@ -927,10 +817,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
.reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
.map(|value| CellValue::Integer(signed_duration_us(value))),
Column::Ipdv => row_seq(row)
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(row)
Column::IpdvUs => row
.seq()
.and_then(|seq| ipdv_pair(context.stats, seq))
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
Column::ServerProcessing => row
@@ -941,7 +833,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
.reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
.map(|value| CellValue::Unsigned(duration_us(value))),
Column::Bytes => row_bytes(row).map(|bytes| CellValue::Unsigned(bytes as u128)),
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)))
@@ -989,14 +881,17 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
_ => None,
},
Column::Message => row_message(row, context).map(CellValue::text),
Column::EventWallNs => row_event_wall(row)
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(row)
Column::ClientSendWallNs => row
.client_send_wall()
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::ClientReceiveWallNs => row_client_receive_wall(row)
Column::ClientReceiveWallNs => row
.client_receive_wall()
.and_then(wall_time_ns)
.map(CellValue::Unsigned),
Column::DurationNs => match row {
@@ -1026,110 +921,6 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
}
}
fn row_seq(row: &OutputRow) -> Option<u32> {
match row {
OutputRow::EchoSent { seq, .. }
| OutputRow::Loss { seq, .. }
| OutputRow::Duplicate { seq, .. } => Some(*seq),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.seq),
_ => None,
}
}
fn row_remote(row: &OutputRow) -> Option<SocketAddr> {
match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.remote),
OutputRow::SessionClosed { remote, .. }
| OutputRow::EchoSent { remote, .. }
| OutputRow::Duplicate { remote, .. } => Some(*remote),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.remote),
_ => None,
}
}
fn row_token(row: &OutputRow) -> Option<u64> {
match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => row.token,
OutputRow::SessionClosed { token, .. } => Some(*token),
_ => None,
}
}
fn row_bytes(row: &OutputRow) -> Option<usize> {
match row {
OutputRow::EchoSent { bytes, .. } | OutputRow::Duplicate { bytes, .. } => Some(*bytes),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.bytes),
_ => None,
}
}
fn row_message(row: &OutputRow, context: RenderContext<'_>) -> Option<String> {
match row {
OutputRow::SessionStarted(row) => Some(format!(
"token={:#x} duration_ns={} interval_ns={} length={}",
row.token?, row.duration_ns, row.interval_ns, row.payload_length
)),
OutputRow::NoTestCompleted(row) => Some(format!(
"duration_ns={} interval_ns={} length={}",
row.duration_ns, row.interval_ns, row.payload_length
)),
OutputRow::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
OutputRow::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
OutputRow::Warning { message, .. } => Some(message.clone()),
OutputRow::Loss { .. } => Some("timeout".to_owned()),
OutputRow::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
_ => None,
}
}
fn row_event_wall(row: &OutputRow) -> Option<SystemTime> {
match row {
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.event_wall),
OutputRow::SessionClosed { event_wall, .. } | OutputRow::Warning { event_wall, .. } => {
Some(*event_wall)
}
OutputRow::EchoSent {
client_send_wall, ..
}
| OutputRow::Loss {
client_send_wall, ..
} => Some(*client_send_wall),
OutputRow::Duplicate {
client_receive_wall,
..
} => Some(*client_receive_wall),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
Some(reply.client_receive_wall)
}
}
}
fn row_client_send_wall(row: &OutputRow) -> Option<SystemTime> {
match row {
OutputRow::EchoSent {
client_send_wall, ..
}
| OutputRow::Loss {
client_send_wall, ..
} => Some(*client_send_wall),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => reply.client_send_wall,
_ => None,
}
}
fn row_client_receive_wall(row: &OutputRow) -> Option<SystemTime> {
match row {
OutputRow::Duplicate {
client_receive_wall,
..
} => Some(*client_receive_wall),
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
Some(reply.client_receive_wall)
}
_ => None,
}
}
fn server_timing_i64(
row: &OutputRow,
select: impl FnOnce(ServerTiming) -> Option<i64>,
@@ -1140,7 +931,7 @@ fn server_timing_i64(
.map(|value| CellValue::Integer(i128::from(value)))
}
fn ipdv_pair(stats: Option<&EventRenderStats>, seq: u32) -> Option<&IpdvPair> {
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
let stats = stats?;
stats
.ipdv_pairs
+192 -370
View File
@@ -1,35 +1,31 @@
use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
io::{self, BufRead, Write},
sync::{atomic::AtomicBool, Arc, Mutex},
sync::Arc,
thread,
time::Duration,
};
use irtt_client::{
managed::{
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
use super::{
args::ClientArgs,
output::{EventRenderStats, OutputConfig},
};
use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
expected_probe_count, parse_stdin_target_set,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary,
},
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
worker::ManagedWorker,
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
};
use irtt_stats::{StatsCollector, StatsConfig};
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
const MIB: u64 = 1024 * 1024;
@@ -40,20 +36,20 @@ 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 MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub fn run_stream(
pub async fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
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 = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
};
let multi_target = setup.is_multi_target();
let output_config = OutputConfig::new(
args.format,
@@ -63,7 +59,7 @@ pub fn run_stream(
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown_requested);
return run_stdin_stream(setup, output_config, shutdown).await;
}
let continuous = args.is_continuous();
let target_count = setup.target_count();
@@ -71,14 +67,13 @@ pub fn run_stream(
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
eprintln!("{warning}");
}
if is_shutdown_requested(shutdown_requested) {
if *shutdown.borrow() {
return Ok(());
}
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
)?;
let handle = owner.handle();
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,
@@ -102,9 +97,9 @@ pub fn run_stream(
let mut dropped_events = 0_u64;
let mut interrupted = false;
let mut stop_requested = false;
let mut subscription_closed = false;
loop {
if is_shutdown_requested(shutdown_requested) {
let snapshot = status.borrow_and_update().clone();
if *shutdown.borrow() {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
@@ -113,38 +108,33 @@ pub fn run_stream(
if request_managed_stop_for_peer_close(
continuous,
interrupted,
handle.status().peer_closed_target_outcomes,
snapshot.peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
let drain_state = drain_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
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()?;
drain_final_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
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}");
}
@@ -159,7 +149,7 @@ pub fn run_stream(
report_target_failure(target);
}
}
interrupted |= is_shutdown_requested(shutdown_requested);
interrupted |= *shutdown.borrow();
let terminal_error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
@@ -201,98 +191,9 @@ pub fn run_stream(
}
#[derive(Clone)]
struct StdinTargetSet {
revision: u64,
targets: Vec<ManagedTargetConfig>,
}
#[derive(Default)]
struct StdinMailboxState {
next_revision: u64,
latest: Option<StdinTargetSet>,
eof: bool,
fatal: Option<String>,
}
#[derive(Default)]
struct StdinMailbox {
state: Mutex<StdinMailboxState>,
}
impl StdinMailbox {
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next_revision = state.next_revision.saturating_add(1);
state.latest = Some(StdinTargetSet {
revision: state.next_revision,
targets,
});
}
fn finish(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof = true;
}
fn fail(&self, error: String) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.fatal = Some(error);
state.latest = None;
}
fn latest(&self) -> Option<StdinTargetSet> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.clone()
}
fn clear_if_current(&self, revision: u64) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
{
state.latest = None;
}
}
fn is_current(&self, revision: u64) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
}
fn fatal(&self) -> Option<String> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fatal
.clone()
}
fn is_finished(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof
}
enum StdinUpdate {
Targets(Vec<TargetSpec>),
Stop(StdinStop),
}
fn read_stdin_record<R: BufRead>(
@@ -371,7 +272,10 @@ fn read_stdin_record<R: BufRead>(
}
}
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
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 {
@@ -383,101 +287,41 @@ fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
}
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => {
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
updates.send_replace(StdinUpdate::Targets(targets));
}
Err(error) => {
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"invalid --targets-stdin line {line}: {error}"
))));
return;
}
}
}
Ok(None) => {
mailbox.finish();
updates.send_replace(StdinUpdate::Stop(StdinStop::Eof));
return;
}
Err(_) => {
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
"failed to read --targets-stdin line {}",
line + 1
))));
return;
}
}
}
}
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
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(), &mailbox);
read_stdin_target_sets(&mut stdin.lock(), &updates);
})
.map(|_| ())
}
#[derive(Clone)]
struct RetryAfterStatus {
revision: u64,
status: Arc<ManagedStatus>,
}
fn retry_waits_for_status_change(
retry_after: &mut Option<RetryAfterStatus>,
target_set: &StdinTargetSet,
status: &Arc<ManagedStatus>,
) -> bool {
let Some(retry) = retry_after else {
return false;
};
if retry.revision != target_set.revision {
*retry_after = None;
return false;
}
Arc::ptr_eq(&retry.status, status)
}
fn retry_after_capacity_rejection(
mailbox: &StdinMailbox,
target_set: &StdinTargetSet,
status: Arc<ManagedStatus>,
) -> Option<RetryAfterStatus> {
mailbox
.is_current(target_set.revision)
.then_some(RetryAfterStatus {
revision: target_set.revision,
status,
})
}
fn apply_latest_stdin_target_set(
handle: &ManagedClientHandle,
mailbox: &StdinMailbox,
retry_after: &mut Option<RetryAfterStatus>,
) -> Result<(), String> {
let Some(target_set) = mailbox.latest() else {
*retry_after = None;
return Ok(());
};
let status = handle.status();
if retry_waits_for_status_change(retry_after, &target_set, &status) {
return Ok(());
}
let receipt = handle
.update_targets(target_set.targets.clone())
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
match receipt.blocking_wait() {
Ok(_) => {
mailbox.clear_if_current(target_set.revision);
*retry_after = None;
Ok(())
}
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
Ok(())
}
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
}
}
struct BoundedTargetSet {
limit: usize,
order: VecDeque<TargetInstance>,
@@ -525,12 +369,8 @@ fn process_stdin_event<W: Write>(
let collector = stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
print_events_with_stats(
stream_output,
std::slice::from_ref(&event),
Some(target.id.as_str()),
collector,
)?;
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()) =>
@@ -542,37 +382,6 @@ fn process_stdin_event<W: Write>(
Ok(())
}
fn drain_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_stdin_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
@@ -613,36 +422,20 @@ enum StdinStop {
Fatal(String),
}
fn stdin_stop_request(
shutdown_requested: &AtomicBool,
mailbox: &StdinMailbox,
) -> Option<StdinStop> {
if is_shutdown_requested(shutdown_requested) {
Some(StdinStop::Interrupted)
} else if let Some(error) = mailbox.fatal() {
Some(StdinStop::Fatal(error))
} else if mailbox.is_finished() {
Some(StdinStop::Eof)
} else {
None
}
}
fn run_stdin_stream(
async fn run_stdin_stream(
setup: ManagedRunSetup,
output_config: OutputConfig,
shutdown_requested: &AtomicBool,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
if is_shutdown_requested(shutdown_requested) {
if *shutdown.borrow() {
return Ok(());
}
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
)?;
let handle = owner.handle();
let mailbox = Arc::new(StdinMailbox::default());
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
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 {
@@ -654,59 +447,138 @@ fn run_stdin_stream(
};
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;
let mut subscription_closed = false;
loop {
let status = handle.status();
let snapshot = status.borrow_and_update().clone();
if stop.is_none() {
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(requested_stop);
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
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),
}
} else if let Err(error) =
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
}
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))
{
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(StdinStop::Fatal(error));
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());
}
}
}
let drain_state = drain_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
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()?;
drain_final_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
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}");
}
@@ -766,12 +638,8 @@ fn process_event<W: Write>(
let collector = stats
.entry(target.id.as_str().to_owned())
.or_insert_with(|| StatsCollector::new(stats_config(false)));
print_events_with_stats(
stream_output,
std::slice::from_ref(&event),
Some(target.id.as_str()),
collector,
)?;
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());
@@ -782,37 +650,6 @@ fn process_event<W: Write>(
Ok(())
}
fn drain_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn report_target_failure(target: &ManagedTargetOutcome) {
for message in target_failure_messages(target) {
eprintln!("{message}");
@@ -847,17 +684,15 @@ struct StreamOutput<'a, W: Write> {
out: &'a mut W,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(
fn print_event(
&mut self,
events: &[ClientEvent],
event: &ClientEvent,
target: Option<&str>,
stats_updates: &[EventRenderStats],
update: &EventStatsUpdate,
) -> io::Result<()> {
self.print_header()?;
for (event, stats_update) in events.iter().zip(stats_updates) {
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
writeln!(self.out, "{line}")?;
}
if let Some(line) = self.config.render_event(event, target, Some(update)) {
writeln!(self.out, "{line}")?;
}
Ok(())
}
@@ -890,19 +725,6 @@ impl<W: Write> StreamOutput<'_, W> {
}
}
fn print_events_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
events: &[ClientEvent],
target: Option<&str>,
stats: &mut StatsCollector,
) -> io::Result<()> {
let updates = events
.iter()
.map(|event| EventRenderStats::from(stats.process(event)))
.collect::<Vec<_>>();
stream_output.print_events(events, target, &updates)
}
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
+2 -1
View File
@@ -49,7 +49,7 @@ impl TuiArgs {
///
/// The TUI always requires a target: the parser rejects an empty target
/// list, and this rejects a target set that cannot be labelled uniquely.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
@@ -59,6 +59,7 @@ impl TuiArgs {
stdin_controlled: false,
},
)
.await
}
pub fn is_continuous(&self) -> bool {
+216 -294
View File
@@ -1,356 +1,278 @@
use std::{
collections::HashSet,
io,
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use std::{future::poll_fn, io, time::Duration};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{
is_shutdown_requested,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, ManagedDrainState,
},
session::{drain_final_events, request_managed_stop_once},
worker::ManagedWorker,
},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_client::managed::{
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEventTryRecvError, TargetInstance,
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 RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const INPUT_EVENT_WORK_BUDGET: usize = 128;
const RETRY_DELAY: Duration = Duration::from_millis(1500);
pub fn run_tui(
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
pub async fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
mut shutdown: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let setup = tokio::select! {
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
};
let continuous = args.is_continuous();
if *shutdown.borrow() {
return Ok(());
}
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()),
);
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, &mut next_render, true)?;
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
) {
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());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
terminal.draw(&state)?;
return Err(Box::new(error));
}
};
let handle = owner.handle();
// 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 mut terminal_targets = HashSet::new();
let mut dropped_events = 0;
let mut subscription_closed = false;
loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
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;
}
}
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
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 request_managed_stop_for_peer_close(
continuous,
interrupted,
handle.status().peer_closed_target_outcomes,
&mut stop_requested,
) {
if interrupted && request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
let previous_dropped = dropped_events;
let drain_state = drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
if dropped_events != previous_dropped {
// Data loss changes the meaning of displayed statistics, even while
// ordinary measurement refreshes are paused.
render_if_due(&mut terminal, &state, &mut next_render, true)?;
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;
}
match drain_state {
ManagedDrainState::Empty => {
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
}
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
if force_render {
terminal.draw(&state)?;
// Schedule from the completed draw, never catch up missed frames.
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
render_if_due(&mut terminal, &state, &mut next_render, false)?;
}
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
};
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let outcome = owner.join()?;
drain_final_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
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 outcome.discarded_target_outcomes != 0 {
if !continuous && outcome.discarded_target_outcomes != 0 {
state.set_run_error(format!(
"{} final target outcomes were discarded",
outcome.discarded_target_outcomes
));
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
state.process_target_outcome(target);
}
state.process_target_outcome(target);
}
interrupted |= is_shutdown_requested(shutdown_requested);
interrupted |= *shutdown.borrow();
let error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
.or_else(|| {
(!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
_ => (!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
}),
};
if let Some(error) = error {
state.set_run_error(error.clone());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
terminal.draw(&state)?;
return Err(error.into());
}
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
terminal.draw(&state)?;
Ok(())
}
fn process_tui_event(
event: ManagedEvent,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
) {
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
match event {
ManagedEvent::Client { target, event } => state.process_target_event(&target, &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 } => {
terminal_targets.insert(outcome.target.clone());
state.process_target_outcome(&outcome);
true
}
_ => {}
}
}
fn drain_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> ManagedDrainState {
let previous_dropped = *dropped_events;
let drained = drain_managed_events(events, dropped_events, |event| {
process_tui_event(event, state, terminal_targets);
Ok::<(), std::convert::Infallible>(())
})
.expect("processing TUI managed events is infallible");
if *dropped_events != previous_dropped {
state.mark_dropped_managed_events(*dropped_events);
}
drained
}
fn drain_final_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) {
loop {
match events.try_recv() {
Ok(event) => process_tui_event(event, state, terminal_targets),
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
for _ in 0..INPUT_EVENT_WORK_BUDGET {
if !event::poll(Duration::ZERO)? {
break;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('c')
if key.code == KeyCode::Char('q')
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
break;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.toggle_view();
force_render = true;
}
KeyCode::Char('m') => {
state.cycle_graph_metric();
force_render = true;
}
KeyCode::Left => {
state.pan_graph_left();
force_render = true;
}
KeyCode::Right => {
state.pan_graph_right();
force_render = true;
}
KeyCode::PageUp => {
state.pan_graph_page_left();
force_render = true;
}
KeyCode::PageDown => {
state.pan_graph_page_right();
force_render = true;
}
KeyCode::Home => {
state.jump_graph_oldest();
force_render = true;
}
KeyCode::End => {
state.jump_graph_live();
force_render = true;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
state.zoom_graph_in();
force_render = true;
}
KeyCode::Char('-') => {
state.zoom_graph_out();
force_render = true;
}
KeyCode::Char('0') => {
state.reset_graph_window();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if should_render(now, *next_render, state.paused, force) {
terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL;
}
Ok(())
}
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
let render_wait = if paused {
TUI_WAIT_SLICE
} else {
next_render.saturating_duration_since(Instant::now())
};
render_wait.min(TUI_WAIT_SLICE)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
for paused in [false, true] {
// The production subscription is a broadcast receiver. Overflow the
// real channel rather than synthesizing a lag result or UI state.
let (sender, mut events) = tokio::sync::broadcast::channel(1);
sender.send(ManagedEvent::Started).unwrap();
sender.send(ManagedEvent::Started).unwrap();
let mut state = TuiState::default();
if paused {
state.toggle_pause();
}
let mut dropped = 0;
let mut terminal_targets = HashSet::new();
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
assert_eq!(dropped, 1);
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
for _ in 0..2 {
// Empty drains must not clear the warning on later redraws.
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
terminal
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
.unwrap();
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("incomplete:dropped=1"));
assert_eq!(text.contains("display paused"), paused);
}
}
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
+18 -12
View File
@@ -1,9 +1,9 @@
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
#[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(any(feature = "client", feature = "server", feature = "tui"))]
#[cfg(feature = "server")]
use crate::signal::install_signal_handler;
/// Entry point for the `irtt-rs` multicall dispatcher binary.
@@ -75,9 +75,9 @@ fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
}
}
/// Installs the shutdown signal handler shared by every applet and hands the
/// Installs the shutdown signal handler for synchronous applets and hands the
/// resulting flag to `f`.
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
#[cfg(feature = "server")]
fn with_shutdown_flag(
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -91,10 +91,13 @@ fn with_shutdown_flag(
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested)
})
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"))]
@@ -121,10 +124,13 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
crate::cmd::tui::run_tui(args, shutdown_requested)
})
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"))]
+34 -2
View File
@@ -1,11 +1,23 @@
use std::time::Duration;
use std::{fmt, time::Duration};
use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Debug, Clone, clap::Args)]
#[derive(Clone, clap::Args)]
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.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
@@ -63,6 +75,26 @@ pub struct CommonClientArgs {
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)]
pub enum ClockArg {
Wall,
+41 -21
View File
@@ -1,36 +1,56 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use irtt_client::{
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
SessionRequest, SocketConfig,
};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
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 template carries no server address: the managed driver replaces it
/// per target from that target's configuration, so deriving one here from
/// an arbitrary target would be dead data.
/// The config is endpoint-independent; managed targets own their addresses.
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
ClientConfig {
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
address_family: self.address_family(),
open: OpenPolicy {
negotiation: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
..Default::default()
},
socket_config: SocketConfig {
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,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
+4 -4
View File
@@ -3,18 +3,18 @@ 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, DEFAULT_RECV_TIMEOUT};
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 session::is_shutdown_requested;
pub use targets::{
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
PreparedTarget, TargetArg, TargetSpec,
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
TargetPreparation, TargetSpec,
};
+13 -3
View File
@@ -16,7 +16,7 @@ use irtt_client::{
use super::{
args::CommonClientArgs,
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
PreparedTarget,
};
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
pub pacing: ManagedPacing,
/// Whether this stream may replace its desired set from stdin.
pub stdin_controlled: bool,
/// Preparation policy and family selections for later stdin declarations.
pub preparation: TargetPreparation,
}
impl ManagedRunSetup {
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
///
/// Targets are validated before any configuration is built, so a bad target set
/// fails before the caller can act on a half-prepared run.
pub fn prepare_managed_run(
pub async fn prepare_managed_run(
common: &CommonClientArgs,
duration: Duration,
selection: TargetSelection<'_>,
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
} else {
target_specs(selection.targets)?
};
let targets = prepare_managed_targets(specs)?;
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
.prepare(
specs,
selection
.stdin_controlled
.then_some(STDIN_MAX_DESIRED_TARGETS),
)
.await?;
Ok(ManagedRunSetup {
targets,
client: common.to_client_config(duration),
pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled,
preparation,
})
}
+5 -27
View File
@@ -1,42 +1,20 @@
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(any(feature = "client", feature = "tui"))]
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
#[cfg(any(feature = "client", feature = "tui"))]
pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128;
#[cfg(any(feature = "client", feature = "tui"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedDrainState {
Empty,
Closed,
BudgetExhausted,
}
/// Drain a bounded amount of lossy presentation work before returning to the
/// frontend control loop.
#[cfg(any(feature = "client", feature = "tui"))]
pub(crate) fn drain_managed_events<E>(
pub(crate) fn drain_final_events<E>(
events: &mut ManagedEventSubscription,
dropped_events: &mut u64,
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
) -> Result<ManagedDrainState, E> {
for _ in 0..MANAGED_EVENT_WORK_BUDGET {
) -> 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);
}
Err(ManagedEventTryRecvError::Empty) => return Ok(ManagedDrainState::Empty),
Err(ManagedEventTryRecvError::Closed) => return Ok(ManagedDrainState::Closed),
}
}
Ok(ManagedDrainState::BudgetExhausted)
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
Ok(())
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
+152 -50
View File
@@ -1,9 +1,13 @@
use std::{collections::HashSet, fmt};
use std::{
collections::{HashMap, HashSet},
fmt, io,
net::{IpAddr, SocketAddr},
};
use clap::ValueEnum;
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
ClientAuthConfig,
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
AddressFamily, Authentication, HmacKey,
};
/// One raw positional target captured by Clap.
@@ -35,23 +39,6 @@ pub fn parse_target(input: &str) -> Result<TargetArg, String> {
Ok(TargetArg::new(input))
}
#[derive(Clone, PartialEq, Eq)]
pub enum TargetAuth {
Inherit,
Override(Vec<u8>),
Disable,
}
impl fmt::Debug for TargetAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Inherit => f.write_str("Inherit"),
Self::Override(_) => f.write_str("Override(<redacted>)"),
Self::Disable => f.write_str("Disable"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
@@ -80,16 +67,8 @@ impl fmt::Debug for PreparedTarget {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field(
"auth",
&match &self.managed.auth {
None => TargetAuth::Inherit,
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
TargetAuth::Override(Vec::new())
}
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
},
)
.field("address_family", &self.managed.address_family)
.field("auth", &self.managed.auth)
.finish()
}
}
@@ -216,9 +195,9 @@ fn parse_target_syntax(
Some(index) => {
let value = unescape(&input[index + "@hmac=".len()..])?;
let auth = if value.is_empty() {
TargetAuth::Disable
TargetAuth::Override(Authentication::Unauthenticated)
} else {
TargetAuth::Override(value.into_bytes())
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
};
(&input[..index], auth)
}
@@ -283,23 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = match spec.auth {
TargetAuth::Inherit => None,
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
hmac_key: Some(hmac_key),
}),
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
};
targets.push(PreparedTarget {
label: spec.label,
managed,
});
/// Family selections for the current desired declarations, never resolved IPs.
/// Unchanged stdin declarations retain their selections; removed declarations
/// are forgotten, so adding them again performs fresh discovery.
#[derive(Debug, Clone)]
pub struct TargetPreparation {
family: AddressFamily,
dual_stack: bool,
discovered: HashMap<String, (String, [bool; 2])>,
}
impl TargetPreparation {
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
Self {
family,
dual_stack,
discovered: HashMap::new(),
}
}
Ok(targets)
pub async fn prepare(
mut self,
specs: Vec<TargetSpec>,
maximum_targets: Option<usize>,
) -> Result<(Vec<PreparedTarget>, Self), String> {
let mut targets = Vec::with_capacity(specs.len());
let mut discovered = HashMap::new();
let mut labels = HashSet::new();
for (index, spec) in specs.into_iter().enumerate() {
let families = if let Some(ip) = literal_ip(&spec.addr) {
match (self.family, ip) {
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
return Err(format!(
"target {}: IPv6 literal conflicts with --ipv4",
index + 1
))
}
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
return Err(format!(
"target {}: IPv4 literal conflicts with --ipv6",
index + 1
))
}
_ => None,
}
} else if self.dual_stack {
let families = match self.discovered.get(&spec.label) {
Some((addr, families)) if *addr == spec.addr => *families,
_ => {
let endpoint = discovery_endpoint(&spec.addr)
.map_err(|error| format!("target {}: {error}", index + 1))?;
discovery_families(tokio::net::lookup_host(endpoint).await)
.map_err(|error| format!("target {}: {error}", index + 1))?
}
};
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
Some(families)
} else {
None
};
for target in expand_target(spec, families) {
if !labels.insert(target.label.clone()) {
return Err("duplicate target label after address-family expansion".to_owned());
}
targets.push(target);
}
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
return Err(format!(
"target set exceeds the {maximum}-target limit after address-family expansion"
));
}
}
self.discovered = discovered;
Ok((targets, self))
}
}
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
endpoint
.parse::<SocketAddr>()
.map(|addr| addr.ip())
.ok()
.or_else(|| endpoint.parse().ok())
.or_else(|| {
if endpoint.starts_with('[') && endpoint.ends_with(']') {
format!("{endpoint}:2112")
.parse::<SocketAddr>()
.ok()
.map(|addr| addr.ip())
} else {
None
}
})
}
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
match endpoint.rsplit_once(':') {
Some((host, port)) => {
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
if host.is_empty() || host.contains(':') || host.starts_with('[') {
return Err("invalid server endpoint".to_owned());
}
Ok((host, port))
}
None => Ok((endpoint, 2112)),
}
}
fn discovery_families(
result: io::Result<impl Iterator<Item = SocketAddr>>,
) -> Result<[bool; 2], String> {
let mut families = [false; 2];
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
families[usize::from(addr.is_ipv6())] = true;
}
if families == [false; 2] {
return Err("DNS lookup returned no usable addresses".to_owned());
}
Ok(families)
}
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
let selections = match families {
None => vec![(spec.label, None)],
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
.into_iter()
.zip(families)
.filter(|(_, exists)| *exists)
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
.collect(),
};
selections
.into_iter()
.map(|(label, family)| {
let mut managed =
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
managed.address_family = family;
managed.auth = spec.auth.clone();
PreparedTarget { label, managed }
})
.collect()
}
/// Parse one complete stdin target set after its line terminator was removed.
@@ -309,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
) -> Result<Vec<TargetSpec>, String> {
if record == "[]" {
return Ok(Vec::new());
}
@@ -320,5 +422,5 @@ pub fn parse_stdin_target_set(
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
prepare_managed_targets(target_specs_with_empty(&args, true)?)
target_specs_with_empty(&args, true)
}
@@ -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();
}
}
}
+12
View File
@@ -1,10 +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)
}
-479
View File
@@ -1,479 +0,0 @@
#[cfg(feature = "client")]
#[path = "support/in_tree_server.rs"]
mod in_tree_server;
#[cfg(feature = "client")]
use irtt_server::ServerConfig;
use std::process::Command;
#[cfg(feature = "client")]
struct ClientProcess(std::process::Child);
#[cfg(feature = "client")]
impl Drop for ClientProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(feature = "client")]
impl ClientProcess {
fn wait(&mut self) -> std::process::ExitStatus {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(status) = self.0.try_wait().unwrap() {
return status;
}
assert!(
std::time::Instant::now() < deadline,
"client did not terminate within five seconds"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
// Dispatch/help/availability belong to the application and run in every build.
#[test]
fn dispatcher_reports_and_runs_the_enabled_applets() {
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
let help = Command::new(dispatcher).arg("--help").output().unwrap();
assert!(help.status.success());
let help = String::from_utf8(help.stdout).unwrap();
for (applet, enabled) in [
("client", cfg!(feature = "client")),
("server", cfg!(feature = "server")),
("tui", cfg!(feature = "tui")),
] {
let row = help
.lines()
.find(|line| line.trim_start().starts_with(applet))
.unwrap();
assert_eq!(row.contains("not available"), !enabled);
let output = Command::new(dispatcher)
.args([applet, "--help"])
.output()
.unwrap();
assert_eq!(output.status.success(), enabled, "{applet}");
if !enabled {
assert!(String::from_utf8(output.stderr)
.unwrap()
.contains("not available"));
}
}
let binaries = [
dispatcher,
#[cfg(feature = "client")]
env!("CARGO_BIN_EXE_irtt-client"),
#[cfg(feature = "server")]
env!("CARGO_BIN_EXE_irtt-server"),
#[cfg(feature = "tui")]
env!("CARGO_BIN_EXE_irtt-tui"),
];
for binary in binaries {
assert!(Command::new(binary)
.arg("--help")
.output()
.unwrap()
.status
.success());
assert!(!Command::new(binary)
.arg("--invalid-option")
.output()
.unwrap()
.status
.success());
}
}
// The default presentation must reach a meaningful packet summary for a finite run.
#[cfg(feature = "client")]
#[test]
fn default_client_output_emits_a_packet_summary() {
use std::{io::Read, process::Stdio, thread};
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
let mut client = ClientProcess(
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.env_clear()
.args([
"--duration",
"100ms",
"--interval",
"20ms",
&server.addr.to_string(),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let mut stdout = client.0.stdout.take().unwrap();
let stdout = thread::spawn(move || {
let mut text = String::new();
stdout.read_to_string(&mut text).unwrap();
text
});
let mut stderr = client.0.stderr.take().unwrap();
let stderr = thread::spawn(move || {
let mut text = String::new();
stderr.read_to_string(&mut text).unwrap();
text
});
let status = client.wait();
let stdout = stdout.join().unwrap();
let stderr = stderr.join().unwrap();
assert!(status.success(), "{stderr}");
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
let packets = stdout
.lines()
.find(|line| line.starts_with("packets:"))
.expect("default output must include the packet summary");
for field in ["sent=", "received="] {
let count = packets
.split_whitespace()
.find_map(|word| word.strip_prefix(field))
.unwrap()
.parse::<u64>()
.unwrap();
assert!(
count > 0,
"summary must report {field} a positive count: {packets}"
);
}
}
#[cfg(feature = "client")]
#[test]
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
use clap::Parser;
use irtt_app::cmd::client::args::ClientArgs;
let args = ClientArgs::try_parse_from([
"irtt-client",
"--hmac",
"global-test-key",
"inherit=127.0.0.1:2112",
"override=127.0.0.1:2113@hmac=target-test-key",
"disable=127.0.0.1:2114@hmac=",
])
.unwrap();
let setup = args.prepare().unwrap();
assert_eq!(
setup.managed_config().client.hmac_key.as_deref(),
Some(b"global-test-key".as_slice())
);
let targets = setup.managed_targets();
let authentication: Vec<_> = targets
.iter()
.map(|target| target.auth.as_ref().map(|auth| auth.hmac_key.as_deref()))
.collect();
assert_eq!(
authentication,
[None, Some(Some(b"target-test-key".as_slice())), Some(None)]
);
}
// A real authenticated server detects broken client argument-to-config mapping.
#[cfg(feature = "client")]
#[test]
fn client_arguments_export_a_real_client_run() {
use std::{io::Read, process::Stdio, thread};
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
for option in ["--dscp", "--ttl"] {
assert!(!Command::new(dispatcher)
.args(["client", option, "256", "127.0.0.1:9"])
.output()
.unwrap()
.status
.success());
}
let server =
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
let mut client = ClientProcess(
Command::new(dispatcher)
.args([
"client",
"--duration",
"100ms",
"--interval",
"20ms",
"--hmac",
"cli-test",
"--length",
"128",
"--sfill",
"pattern:ab",
"--clock",
"wall",
"--tstamp",
"both",
"--stats",
"both",
"--ttl",
"64",
"--format",
"jsonl",
"--columns",
"target,event,bytes",
&format!("edge={}", server.addr),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
// Drain both pipes while waiting so output cannot block client termination.
let mut stdout = client.0.stdout.take().unwrap();
let stdout = thread::spawn(move || {
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).unwrap();
bytes
});
let mut stderr = client.0.stderr.take().unwrap();
let stderr = thread::spawn(move || {
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).unwrap();
bytes
});
let status = client.wait();
let stdout = stdout.join().unwrap();
let stderr = stderr.join().unwrap();
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
let stdout = String::from_utf8(stdout).unwrap();
let rows: Vec<_> = stdout.lines().collect();
assert!(!rows.is_empty());
assert!(rows.iter().all(|row| row.starts_with('{')
&& row.ends_with('}')
&& row.contains("\"target\":\"edge\"")));
assert!(rows
.iter()
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
assert!(rows
.iter()
.any(|row| row.contains("\"event\":\"session_closed\"")));
}
#[cfg(any(feature = "client", feature = "tui"))]
#[test]
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
let secret = "malformed-target-secret-92817";
for applet in [
#[cfg(feature = "client")]
"client",
#[cfg(feature = "tui")]
"tui",
] {
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.trim().is_empty(),
"malformed target must report a diagnostic"
);
assert!(!stderr.contains(secret), "target authentication key leaked");
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
}
}
#[cfg(feature = "server")]
#[test]
fn server_arguments_resolve_binds_and_map_policy() {
use clap::{CommandFactory, FromArgMatches, Parser};
use irtt_app::cmd::server::ServerArgs;
use irtt_server::TimestampAllowance;
use std::time::Duration;
// Isolate zero-argument defaults from the test runner's environment without
// mutating process-global environment in this parallel test process.
let defaults = ServerArgs::from_arg_matches(
&ServerArgs::command()
.mut_args(|arg| arg.env(None::<&str>))
.try_get_matches_from(["irtt-server"])
.unwrap(),
)
.unwrap();
assert_eq!(
defaults.resolve_binds(),
vec![
"[::]:2112".parse().unwrap(),
"0.0.0.0:2112".parse().unwrap()
]
);
assert_eq!(
defaults.server_config(),
irtt_server::ServerConfig::default()
);
let args = ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:2113",
"--bind",
"[::1]:2114",
"--hmac",
"server-cli-test",
"--max-sessions",
"3",
"--max-packet-length",
"96",
"--min-interval",
"25ms",
"--burst",
"2",
"--idle-timeout",
"8s",
"--max-duration",
"1m",
"--timestamp-allowance",
"single",
"--no-dscp",
])
.unwrap();
assert_eq!(
args.resolve_binds(),
vec![
"127.0.0.1:2113".parse().unwrap(),
"[::1]:2114".parse().unwrap()
]
);
let config = args.server_config();
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
assert_eq!(config.max_sessions(), 3);
assert_eq!(config.max_packet_length(), 96);
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
assert_eq!(config.burst_allowance(), 2);
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
assert!(!config.dscp_allowed());
}
#[cfg(feature = "server")]
#[test]
fn server_command_line_binds_replace_the_environment_bind() {
use std::net::UdpSocket;
// Occupied ports give bounded, observable startup failures, without starting
// a daemon or needing a process/signal harness merely to inspect arguments.
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
let environment_addr = environment.local_addr().unwrap().to_string();
let explicit_addr = explicit.local_addr().unwrap().to_string();
for use_explicit in [false, true] {
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
command
.env_clear()
.env("IRTT_SERVER_BIND", &environment_addr)
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
.args(["--max-sessions", "3"]);
if use_explicit {
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
}
let output = command.output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
let expected = if use_explicit {
&explicit_addr
} else {
&environment_addr
};
assert!(
stderr.contains(expected),
"startup must attempt the selected bind: {stderr}"
);
assert!(
!stderr.contains("listening on"),
"partial startup must not announce listeners"
);
}
}
// Exercise stdin replacement and EOF through the actual application process.
#[cfg(feature = "client")]
#[test]
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
use std::{
io::{BufRead, BufReader, Read, Write},
process::Stdio,
sync::mpsc,
thread,
time::{Duration, Instant},
};
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
let mut client = ClientProcess(
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.env_clear()
.args([
"--duration",
"0",
"--targets-stdin",
"--interval",
"20ms",
"--format",
"jsonl",
"--columns",
"target,event",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let stdout = client.0.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
let reader = thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if tx.send(line.unwrap()).is_err() {
break;
}
}
});
let mut stdin = client.0.stdin.take().unwrap();
let mut rows = Vec::new();
for target in ["first", "second"] {
writeln!(stdin, "{target}={}", server.addr).unwrap();
stdin.flush().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let row = rx
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.expect("stdin target set did not produce a reply");
let replied = row.contains(&format!("\"target\":\"{target}\""))
&& row.contains("\"event\":\"echo_reply\"");
rows.push(row);
if replied {
break;
}
}
}
// Retirement can finish after the newer target's first reply.
let deadline = Instant::now() + Duration::from_secs(5);
while !rows.iter().any(|row| {
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
}) {
rows.push(
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.expect("superseded stdin target did not retire"),
);
}
drop(stdin);
let status = client.wait();
reader.join().unwrap();
rows.extend(rx.try_iter());
let mut stderr = String::new();
client
.0
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
assert!(status.success(), "{stderr}");
assert!(rows
.iter()
.any(|row| row.contains("\"target\":\"second\"")
&& row.contains("\"event\":\"session_closed\"")));
}
@@ -1,74 +0,0 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::mpsc,
thread::{self, JoinHandle},
time::Duration,
};
use irtt_server::{Server, ServerConfig};
use tokio::sync::oneshot;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
pub struct InTreeServer {
pub addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
impl InTreeServer {
pub fn start(config: ServerConfig) -> Self {
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let done = thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build in-tree test server runtime");
runtime.block_on(async move {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let mut server = Server::bind(bind_addr, config).await?;
startup_tx
.send(server.local_addr()?)
.expect("test stopped waiting for in-tree server startup");
server
.run(async {
let _ = shutdown_rx.await;
})
.await
})
});
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(addr) => addr,
Err(error) => {
let _ = shutdown_tx.send(());
let result = done.join().expect("in-tree test server thread panicked");
panic!("in-tree test server failed before startup ({error}): {result:?}");
}
};
Self {
addr,
shutdown: Some(shutdown_tx),
done: Some(done),
}
}
fn stop_inner(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(done) = self.done.take() {
done.join()
.expect("in-tree test server thread panicked")
.expect("in-tree test server stopped with an error");
}
}
}
impl Drop for InTreeServer {
fn drop(&mut self) {
self.stop_inner();
}
}
+39 -1
View File
@@ -2,6 +2,42 @@
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
@@ -84,7 +120,9 @@ 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 through the ordinary `ClientError::Socket` path.
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
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-client"
version = "0.5.3"
version = "0.6.0"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
@@ -15,7 +15,7 @@ categories = ["network-programming"]
irtt-proto.workspace = true
socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.21"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
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 }
+67 -2
View File
@@ -26,14 +26,79 @@ binaries are built on.
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
`EchoSent` events leave `scheduled_at` and `timer_error` absent; the managed
driver supplies those schedule measurements.
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).
+33 -22
View File
@@ -12,32 +12,33 @@
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! 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};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest};
const RUN_DURATION: Duration = Duration::from_secs(2);
fn main() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(RUN_DURATION),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(100)),
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(config) {
let mut client = match Client::connect("127.0.0.1:2112", config) {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
@@ -45,15 +46,18 @@ fn main() {
}
};
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 {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
Ok(OpenOutcome::Started(started)) => {
println!("session opened: {started:?}");
started.negotiation
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
Ok(OpenOutcome::NoTestCompleted(completed)) => {
println!("{completed:?}");
return;
}
Err(err) => {
@@ -63,9 +67,13 @@ fn main() {
};
// This loop owns cadence and duration. The transport sends whenever called.
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
let interval = negotiated.accepted.interval;
let start = Instant::now();
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
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() {
@@ -75,13 +83,16 @@ fn main() {
}
if now < end && now >= next_send {
match client.send_probe() {
Ok(events) => {
events.iter().for_each(print_event);
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;
}
+10 -4
View File
@@ -22,14 +22,20 @@ use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
ManagedTargetConfig,
};
use irtt_client::ClientConfig;
use irtt_client::{ClientConfig, OpenPolicy, SessionRequest};
fn main() {
let config = ManagedClientConfig {
client: ClientConfig {
duration: None,
interval: Duration::from_millis(200),
open_timeouts: vec![Duration::from_millis(300)],
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,
+32 -20
View File
@@ -13,12 +13,14 @@
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! 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};
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
@@ -41,16 +43,21 @@ fn main() {
async fn run() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(Duration::from_secs(2)),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
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(config).await {
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}");
@@ -59,14 +66,12 @@ async fn run() {
};
let negotiated = match client.open().await {
Ok(OpenOutcome::Started {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
Ok(OpenOutcome::Started(started)) => {
println!("session opened: {started:?}");
started.negotiation
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
Ok(OpenOutcome::NoTestCompleted(completed)) => {
println!("{completed:?}");
return;
}
Err(err) => {
@@ -76,9 +81,13 @@ async fn run() {
};
// This loop owns cadence and duration. The transport sends whenever called.
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
let interval = negotiated.accepted.interval;
let start = Instant::now();
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
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() {
@@ -112,13 +121,16 @@ async fn run() {
if Instant::now() < end && Instant::now() >= next_send {
match client.send_probe().await {
Ok(events) => {
events.iter().for_each(print_event);
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;
}
+60 -46
View File
@@ -10,7 +10,7 @@ use std::{
use tokio::time;
use crate::{
client::{echo_sent_event, validate_datagram_length},
client::validate_datagram_length,
config::ClientConfig,
error::ClientError,
event::{ClientEvent, OpenOutcome},
@@ -19,9 +19,10 @@ use crate::{
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
},
socket::{connect_tokio_udp_socket, resolve_remote_tokio, validate_open_timeouts},
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.
@@ -170,7 +171,7 @@ impl AsyncOpenState {
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
/// 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?;
@@ -199,19 +200,26 @@ pub struct AsyncClient {
}
impl AsyncClient {
/// Resolve the configured server and construct one connected Tokio UDP
/// 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.
pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
///
/// 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)?;
validate_open_timeouts(&config.open_timeouts)?;
let remote = resolve_remote_tokio(&config).await?;
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_config, remote)?;
let socket = connect_tokio_udp_socket(&config.socket, remote, config.address_family)?;
Ok(Self {
socket,
@@ -246,8 +254,13 @@ impl AsyncClient {
///
/// 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.
pub async fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
/// 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
}
@@ -307,7 +320,7 @@ impl AsyncClient {
return self.poll_open_cleanup(state, cx);
}
let attempt_count = self.machine.config().open_timeouts.len();
let attempt_count = self.machine.config().open.timeouts.len();
if state.attempt >= attempt_count {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
@@ -316,7 +329,7 @@ impl AsyncClient {
return Poll::Ready(Err(ClientError::OpenTimeout));
}
if state.deadline.is_none() {
let timeout = self.machine.config().open_timeouts[state.attempt];
let timeout = self.machine.config().open.timeouts[state.attempt];
if let Err(error) = state.start_attempt(timeout) {
return Poll::Ready(Err(error));
}
@@ -429,14 +442,14 @@ impl AsyncClient {
pub(crate) fn poll_send_probe(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
) -> Poll<Result<SendReceipt, SendProbeError>> {
if let Err(error) = self.machine.ensure_open() {
return Poll::Ready(Err(error));
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(error)),
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
}
}
loop {
@@ -444,7 +457,9 @@ impl AsyncClient {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
Poll::Ready(Err(error)) => {
return Poll::Ready(Err(ClientError::Socket(error)));
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
error,
))));
}
}
@@ -454,15 +469,8 @@ impl AsyncClient {
.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(error)),
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
};
let mut events = Vec::new();
if let Err(source) = events.try_reserve(1) {
return Poll::Ready(Err(ClientError::AllocationFailed {
operation: "probe event result",
source,
}));
}
let expected_bytes = prepared.bytes.len();
// Private pre-send anchor: finalizes all fallible commit work
@@ -476,7 +484,7 @@ impl AsyncClient {
.finalize_probe_commit(machine_preflight, send_anchor)
{
Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(error)),
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
};
let send_call_start = Instant::now();
let send_result = self.socket.try_send(&prepared.bytes);
@@ -490,7 +498,9 @@ impl AsyncClient {
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(ClientError::Socket(error)));
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
error,
))));
}
};
let send_finished_at = Instant::now();
@@ -505,14 +515,26 @@ impl AsyncClient {
self.prepared_probe = None;
let send_call = send_finished_at.saturating_duration_since(send_call_start);
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
return Poll::Ready(Err(error));
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(error) = self.drain_tx_timestamps() {
return Poll::Ready(Err(error));
if let Err(source) = self.drain_tx_timestamps() {
return Poll::Ready(Err(SendProbeError::AfterCommit {
receipt,
source: Box::new(source),
}));
}
events.push(echo_sent_event(self.remote, sent, send_call));
return Poll::Ready(Ok(events));
return Poll::Ready(Ok(receipt));
}
}
@@ -647,9 +669,11 @@ impl AsyncClient {
self.machine.probe_timeout()
}
/// Negotiated parameters of the currently open session.
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
self.machine.negotiated_params()
/// 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.
@@ -700,17 +724,7 @@ impl AsyncClient {
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
}
};
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
Ok(traffic_class) => traffic_class,
Err(_) => {
return Err(Box::new(PreparedAsyncOpenFailure {
primary: ClientError::InvalidConfig {
reason: "negotiated dscp must be in range 0..=255".to_owned(),
},
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 {
+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()),
}
}
}
+86 -69
View File
@@ -10,12 +10,13 @@ use crate::{
event::{ClientEvent, OpenOutcome},
receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
session::machine::{
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, ProbeSent,
SessionMachine, MAX_OPEN_PACKET_SIZE,
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, SessionMachine,
MAX_OPEN_PACKET_SIZE,
},
socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
socket::{connect_udp_socket, resolve_remote},
socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
timing::ClientTimestamp,
SendProbeError, SendReceipt,
};
#[derive(Debug)]
@@ -50,7 +51,7 @@ struct PreparedClientOpenFailure {
/// use irtt_client::{Client, ClientConfig};
///
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = Client::connect(ClientConfig::default())?;
/// let mut client = Client::connect("127.0.0.1:2112", ClientConfig::default())?;
/// let outcome = client.open()?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe()?;
@@ -70,6 +71,7 @@ pub struct Client {
socket: UdpSocket,
remote: SocketAddr,
recv_buffer: Vec<u8>,
recv_timeout: Option<Duration>,
applied_traffic_class: Option<u8>,
/// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
/// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
@@ -81,32 +83,53 @@ pub struct Client {
}
impl Client {
/// Resolve the configured server and create a connected UDP socket.
/// Resolve the endpoint and create a connected UDP socket.
///
/// This validates local configuration and prepares the open request, but it
/// does not contact the server. Call [`open`](Self::open) to perform the
/// IRTT open exchange.
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
validate_open_timeouts(&config.open_timeouts)?;
let remote = resolve_remote(&config)?;
///
/// 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 fn connect(endpoint: impl AsRef<str>, config: ClientConfig) -> Result<Self, ClientError> {
config.open.validate()?;
let remote = resolve_remote(endpoint.as_ref(), config.address_family)?;
let runtime = SessionMachine::new(config.clone(), remote)?;
let socket = connect_udp_socket(&config.socket_config, remote)?;
let socket = connect_udp_socket(&config.socket, remote, config.address_family)?;
Ok(Self {
runtime,
socket,
remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
recv_timeout: None,
applied_traffic_class: None,
tx_timestamping_enabled: false,
})
}
/// Set the blocking receive timeout used outside open attempts.
///
/// `None` (the default) lets `recv_once()` block indefinitely. A nonzero
/// timeout bounds one logical receive, including retries after EINTR.
/// `recv_available()` retains its bounded drain behavior. Open attempts
/// use only [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts) and
/// restore this setting on success or failure.
///
/// A zero duration is rejected by the socket; the previous setting is
/// retained when applying the new timeout fails.
pub fn set_recv_timeout(&mut self, timeout: Option<Duration>) -> Result<(), ClientError> {
self.socket.set_read_timeout(timeout)?;
self.recv_timeout = timeout;
Ok(())
}
/// Perform the IRTT open exchange.
///
/// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
/// Open attempts use [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts). Malformed or
/// unrelated datagrams are ignored until the current attempt's absolute
/// deadline, so one attempt may consume several datagrams without
/// retransmitting. Silence or ignored traffic eventually produces
@@ -121,9 +144,7 @@ impl Client {
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let result = self.open_transaction();
if result.is_err() {
let _ = self
.socket
.set_read_timeout(self.runtime.config().socket_config.recv_timeout);
let _ = self.socket.set_read_timeout(self.recv_timeout);
}
result
}
@@ -173,18 +194,19 @@ impl Client {
/// Send one echo probe now. The caller owns pacing and run duration.
///
/// The negotiated interval and duration do not gate low-level sends.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
/// A receipt means socket acceptance and infallible session commitment have
/// completed. Failures before acceptance return [`SendProbeError::NotCommitted`];
/// later validation or TX processing failures preserve the receipt in
/// [`SendProbeError::AfterCommit`].
pub fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
let remote = self.remote;
let (runtime, socket) = (&mut self.runtime, &self.socket);
let prepared = runtime.prepare_probe()?;
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
let mut events = Vec::new();
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
operation: "probe event result",
source,
})?;
let prepared = runtime
.prepare_probe()
.map_err(SendProbeError::NotCommitted)?;
let machine_preflight = runtime
.preflight_probe_commit(&prepared)
.map_err(SendProbeError::NotCommitted)?;
let expected_bytes = prepared.bytes.len();
// All fallible probe-commit preflight (timeout deadline arithmetic,
@@ -192,14 +214,16 @@ impl Client {
// this PRIVATE pre-send anchor, before the socket send. It is not
// the public measurement `sent_at` captured further below.
let send_anchor = ClientTimestamp::now();
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
let machine_commit = runtime
.finalize_probe_commit(machine_preflight, send_anchor)
.map_err(SendProbeError::NotCommitted)?;
let send_call_start = Instant::now();
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
return Err(SendProbeError::NotCommitted(ClientError::Socket(error)));
}
};
let send_finished_at = Instant::now();
@@ -211,20 +235,36 @@ impl Client {
let sent_at = ClientTimestamp::now();
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
let send_call = send_finished_at.saturating_duration_since(send_call_start);
validate_datagram_length(expected_bytes, bytes)?;
self.drain_tx_timestamps()?;
let receipt = SendReceipt {
seq: sent.seq,
remote,
sent_at: sent.sent_at,
bytes: sent.bytes,
send_call,
};
validate_datagram_length(expected_bytes, bytes).map_err(|source| {
SendProbeError::AfterCommit {
receipt,
source: Box::new(source),
}
})?;
self.drain_tx_timestamps()
.map_err(|source| SendProbeError::AfterCommit {
receipt,
source: Box::new(source),
})?;
events.push(echo_sent_event(remote, sent, send_call));
Ok(events)
Ok(receipt)
}
/// Receive and classify at most one datagram from the socket.
///
/// Returns an empty event list when the socket read would block or times
/// out. Malformed or unrelated datagrams are reported as warning events.
/// Requires an open session; lifecycle errors are returned before socket I/O.
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.socket
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
self.runtime.ensure_open()?;
self.socket.set_read_timeout(self.recv_timeout)?;
self.recv_once_inner()
}
@@ -265,7 +305,7 @@ impl Client {
fn recv_datagram_retrying_interrupted(
&mut self,
) -> Result<Option<ReceivedDatagram>, ClientError> {
let configured_timeout = self.runtime.config().socket_config.recv_timeout;
let configured_timeout = self.recv_timeout;
let deadline = match configured_timeout {
Some(timeout) => Some(
Instant::now()
@@ -316,9 +356,11 @@ impl Client {
/// Receive and classify datagrams until a receive produces no events or the
/// receive budget is exhausted.
///
/// Requires an open session; lifecycle errors are returned before socket I/O.
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
self.socket
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
self.runtime.ensure_open()?;
self.socket.set_read_timeout(self.recv_timeout)?;
let mut all_events = Vec::new();
for _ in 0..budget.max_packets {
let events = self.recv_once_inner()?;
@@ -350,9 +392,11 @@ impl Client {
self.runtime.poll_timeouts_at(now)
}
/// Negotiated parameters of the currently open session.
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
self.runtime.negotiated_params()
/// 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.runtime.negotiation()
}
/// Earliest timeout deadline among probes still awaiting a reply.
@@ -378,10 +422,10 @@ impl Client {
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
let request = self.runtime.prepare_open_request()?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
let attempt_count = self.runtime.config().open_timeouts.len();
let attempt_count = self.runtime.config().open.timeouts.len();
for attempt in 0..attempt_count {
let timeout = self.runtime.config().open_timeouts[attempt];
let timeout = self.runtime.config().open.timeouts[attempt];
let deadline = Instant::now()
.checked_add(timeout)
.ok_or(ClientError::DurationOverflow)?;
@@ -465,7 +509,7 @@ impl Client {
recv_buffer_len: None,
negotiated_traffic_class: None,
previous_traffic_class: self.applied_traffic_class,
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
post_open_recv_timeout: self.recv_timeout,
});
};
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
@@ -473,24 +517,13 @@ impl Client {
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
};
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
Ok(traffic_class) => traffic_class,
Err(_) => {
return Err(Box::new(PreparedClientOpenFailure {
primary: ClientError::InvalidConfig {
reason: "negotiated dscp must be in range 0..=255".to_owned(),
},
machine,
}));
}
};
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
Ok(PreparedClientOpen {
machine,
recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class),
previous_traffic_class: self.applied_traffic_class,
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
post_open_recv_timeout: self.recv_timeout,
})
}
@@ -590,22 +623,6 @@ impl Client {
}
}
pub(crate) fn echo_sent_event(
remote: SocketAddr,
sent: ProbeSent,
send_call: Duration,
) -> ClientEvent {
ClientEvent::EchoSent {
seq: sent.seq,
remote,
scheduled_at: None,
sent_at: sent.sent_at,
bytes: sent.bytes,
send_call,
timer_error: None,
}
}
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
if actual == expected {
Ok(())
+120 -95
View File
@@ -1,10 +1,12 @@
use std::{net::SocketAddr, time::Duration};
use crate::{Authentication, ClientError};
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
/// bytes.
///
/// 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.
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt};
@@ -26,7 +28,7 @@ pub const MAX_TTL: u32 = 255;
/// Largest UDP payload length accepted by client configuration, in bytes.
///
/// 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(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
@@ -45,7 +47,7 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// 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
/// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams).
/// through [`NegotiationResult`](crate::NegotiationResult).
///
/// # Example
///
@@ -56,29 +58,82 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// ```
/// use std::time::Duration;
///
/// use irtt_client::{ClientConfig, NegotiationPolicy};
/// use irtt_client::{ClientConfig, NegotiationPolicy, OpenPolicy, SessionRequest};
///
/// let config = ClientConfig {
/// server_addr: "example.net:2112".into(),
/// duration: Some(Duration::from_secs(10)),
/// interval: Duration::from_millis(250),
/// length: 1200,
/// negotiation_policy: NegotiationPolicy::Loose,
/// 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.duration, Some(Duration::from_secs(10)));
/// assert_eq!(config.interval, Duration::from_millis(250));
/// assert_eq!(config.length, 1200);
/// 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)]
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
/// literals may be supplied either bracketed or unbracketed when the
/// default port should be used.
pub server_addr: String,
/// HMAC peers must use the same key. An empty HMAC key remains authenticated.
pub auth: Authentication,
/// Time after sending a probe before the client reports it as lost.
///
/// This timeout is local client behavior; it is not negotiated with the
/// server. It must be greater than zero.
pub probe_timeout: Duration,
/// Maximum number of probes tracked as pending/timed-out/completed.
///
/// This bounds memory used for reply classification and must be greater
/// than zero. A very small value can reject sends when replies are still
/// outstanding.
pub max_pending_probes: usize,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
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.
@@ -118,71 +173,20 @@ pub struct ClientConfig {
/// 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.
///
/// This timeout is local client behavior; it is not negotiated with the
/// server. It must be greater than zero.
pub probe_timeout: Duration,
/// Maximum number of probes tracked as pending/timed-out/completed.
///
/// This bounds memory used for reply classification and must be greater
/// than zero. A very small value can reject sends when replies are still
/// outstanding.
pub max_pending_probes: usize,
}
/// Authentication settings used by client sessions.
///
/// `ClientConfig` keeps its existing top-level HMAC field for source
/// compatibility. Managed multi-target callers can use this smaller type for
/// per-target auth overrides without carrying a full independent
/// [`ClientConfig`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientAuthConfig {
/// Optional HMAC key used to authenticate IRTT packets.
pub hmac_key: Option<Vec<u8>>,
}
impl From<Option<Vec<u8>>> for ClientAuthConfig {
fn from(hmac_key: Option<Vec<u8>>) -> Self {
Self { hmac_key }
}
}
impl From<ClientAuthConfig> for Option<Vec<u8>> {
fn from(auth: ClientAuthConfig) -> Self {
auth.hmac_key
}
}
impl Default for ClientConfig {
impl Default for SessionRequest {
fn default() -> Self {
Self {
server_addr: format!("127.0.0.1:{DEFAULT_PORT}"),
duration: Some(DEFAULT_DURATION),
interval: DEFAULT_INTERVAL,
length: 0,
@@ -190,23 +194,55 @@ impl Default for ClientConfig {
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 0,
hmac_key: None,
server_fill: None,
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
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`].
///
/// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the
/// server.
/// These settings affect how the client binds and sends from the socket. They
/// do not change the IRTT protocol parameters negotiated with the server.
///
/// # Example
///
@@ -215,20 +251,20 @@ impl Default for ClientConfig {
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{ClientConfig, SocketConfig};
/// use irtt_client::{AddressFamily, ClientConfig, SocketConfig};
///
/// let config = ClientConfig {
/// socket_config: SocketConfig {
/// address_family: AddressFamily::Ipv4,
/// socket: SocketConfig {
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
/// ttl: Some(32),
/// ipv4_only: true,
/// ..SocketConfig::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.socket_config.ttl, Some(32));
/// assert!(config.socket_config.ipv4_only);
/// assert_eq!(config.socket.ttl, Some(32));
/// assert_eq!(config.address_family, AddressFamily::Ipv4);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig {
@@ -252,17 +288,6 @@ pub struct SocketConfig {
/// platform socket option range; [`MAX_TTL`] is the public configuration
/// bound used by this crate.
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.
@@ -272,7 +297,7 @@ pub enum NegotiationPolicy {
/// than requested.
Strict,
/// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams).
/// [`NegotiationResult`](crate::NegotiationResult).
Loose,
}
+30 -45
View File
@@ -3,35 +3,36 @@ use std::{
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
/// probes are driven. Managed sessions publish the contained lifecycle event to
/// subscribers immediately after a successful open.
/// Managed sessions publish the same payload as a [`ClientEvent`] after Open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenOutcome {
/// A normal probe session was opened.
Started {
/// 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,
},
Started(SessionStarted),
NoTestCompleted(NoTestCompleted),
}
/// Event emitted by an IRTT client session.
@@ -44,28 +45,12 @@ pub enum OpenOutcome {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent {
/// A normal session has opened and is ready to send echo probes.
SessionStarted {
/// Resolved remote socket address.
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.
SessionStarted(SessionStarted),
/// A no-test exchange completed without creating a live session.
///
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
/// expected for this open outcome.
NoTestCompleted {
/// Resolved remote socket address.
remote: SocketAddr,
/// Negotiated protocol parameters returned by the server.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// expected for this outcome.
NoTestCompleted(NoTestCompleted),
/// The session closed locally or through an authenticated peer close.
///
/// For a peer close-flagged echo reply, the reply's measurement or
+26 -6
View File
@@ -19,7 +19,11 @@
//! `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 `negotiated_params()` after Open. [`managed`] owns
//! 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
@@ -28,12 +32,24 @@
//! current-thread runtime for callers that want managed multi-target
//! behavior without touching Tokio themselves.
//!
//! A successful [`OpenOutcome`] carries either [`SessionStarted`] (with a live
//! 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.
//!
//! See `examples/` in the repository for a runnable example of each tier.
//!
#![forbid(unsafe_code)]
#[cfg(feature = "tokio")]
mod async_client;
mod auth;
mod client;
mod config;
mod error;
@@ -42,6 +58,7 @@ pub mod managed;
mod metadata;
mod probe;
mod receive;
mod send;
mod session;
mod socket;
mod socket_options;
@@ -49,15 +66,18 @@ mod timing;
#[cfg(feature = "tokio")]
pub use async_client::AsyncClient;
pub use auth::{Authentication, HmacKey};
pub use client::Client;
pub use config::{
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
AddressFamily, ClientConfig, NegotiationPolicy, OpenPolicy, RecvBudget, RunMode,
SessionRequest, SocketConfig, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL,
MAX_UDP_PAYLOAD_LENGTH,
};
pub use error::ClientError;
pub use event::{
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
};
pub use session::{NegotiatedParams, NegotiationRestriction};
pub use send::{SendProbeError, SendReceipt};
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
pub use timing::ClientTimestamp;
+56 -4
View File
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
use thiserror::Error;
use crate::{ClientAuthConfig, ClientConfig, ClientError, ClientEvent};
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
use super::TargetId;
@@ -26,12 +26,39 @@ pub struct TargetInstance {
pub generation: u64,
}
/// Endpoint and optional authentication override for one managed target.
/// 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,
pub auth: Option<ClientAuthConfig>,
/// 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 {
@@ -39,7 +66,8 @@ impl ManagedTargetConfig {
Self {
id: id.into(),
server_addr: server_addr.into(),
auth: None,
address_family: None,
auth: TargetAuth::Inherit,
}
}
}
@@ -63,6 +91,8 @@ pub enum ManagedCompletionPolicy {
/// 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,
@@ -117,6 +147,12 @@ 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>,
}
@@ -125,6 +161,10 @@ pub struct ManagedTargetStatus {
#[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,
@@ -141,9 +181,21 @@ pub struct ManagedStatus {
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)]
+56 -15
View File
@@ -1,6 +1,6 @@
use std::time::{Duration, Instant};
use crate::{error::ClientError, session::NegotiatedParams};
use crate::{error::ClientError, session::AcceptedSessionParameters};
#[derive(Debug)]
pub(crate) struct ProbeSchedule {
@@ -18,24 +18,19 @@ pub(crate) struct ScheduleCommit {
impl ProbeSchedule {
pub(crate) fn new(
start_at: Instant,
negotiated: &NegotiatedParams,
accepted: &AcceptedSessionParameters,
) -> Result<Self, ClientError> {
let interval_ns = u64::try_from(negotiated.params.interval_ns)
.expect("validated positive negotiated interval");
let interval = Duration::from_nanos(interval_ns);
let end_at = if negotiated.params.duration_ns > 0 {
let duration_ns = u64::try_from(negotiated.params.duration_ns)
.expect("validated positive negotiated duration");
Some(
let interval = accepted.interval;
let end_at = accepted
.duration
.map(|duration| {
start_at
.checked_add(Duration::from_nanos(duration_ns))
.checked_add(duration)
.ok_or_else(|| ClientError::NegotiationRejected {
reason: "duration is too large to schedule".to_owned(),
})?,
)
} else {
None
};
})
})
.transpose()?;
Ok(Self {
end_at,
@@ -48,6 +43,11 @@ impl ProbeSchedule {
self.next_send_at
}
/// Wake for finite expiry independently of send readiness until finished.
pub(crate) fn end_deadline(&self) -> Option<Instant> {
self.end_at.filter(|_| !self.is_finished())
}
pub(crate) fn interval(&self) -> Duration {
self.interval
}
@@ -152,3 +152,44 @@ pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
.or_else(|| right.checked_duration_since(left))
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
let start = Instant::now();
let end = start + Duration::from_millis(500);
let mut accepted = AcceptedSessionParameters {
duration: Some(Duration::from_millis(500)),
interval: Duration::from_millis(1),
length: 0,
received_stats: irtt_proto::ReceivedStats::None,
stamp_at: irtt_proto::StampAt::None,
clock: irtt_proto::Clock::Both,
dscp: 0,
server_fill: None,
};
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
assert_eq!(finite.end_deadline(), Some(end));
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
assert_eq!(finite.end_deadline(), Some(end));
assert!(!finite.permit_probe_at(end));
assert!(finite.is_finished());
assert_eq!(finite.end_deadline(), None);
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
let commit = finite
.preflight_managed_commit(start, end - Duration::from_nanos(1))
.unwrap();
finite.commit(commit);
assert!(finite.is_finished());
assert_eq!(finite.end_deadline(), None);
accepted.duration = None;
let mut continuous = ProbeSchedule::new(start, &accepted).unwrap();
assert!(continuous.permit_probe_at(end));
assert_eq!(continuous.end_deadline(), None);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,514 @@
//! Task construction, public control/observation handles, and stop/update admission.
use std::{
collections::{HashSet, VecDeque},
fmt,
future::Future,
pin::Pin,
sync::{
atomic::{AtomicBool, AtomicU8, Ordering},
Arc,
},
task::{Context, Poll},
time::Instant,
};
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use crate::{
managed::{
ManagedClientConfig, ManagedCommandAcknowledgement, ManagedCommandApplyError,
ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedEvent,
ManagedEventSubscription, ManagedLifecycle, ManagedStatus, ManagedStatusSubscription,
ManagedSubscribeError, ManagedTargetConfig, ManagedTargetLifecycle, ManagedTargetStatus,
TargetInstance,
},
session::machine::SessionMachine,
socket_options::validate_ttl,
ClientConfig, ClientError,
};
use super::{
arm_wake,
target::{TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
timeouts::TIMEOUT_WORK_BUDGET,
ManagedClientTask, OutcomeHistory, TaskPhase, TaskResources,
};
// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
// power of two; Tokio does not expose it as a public constant.
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
/// Entry point for constructing a unified Tokio managed task.
///
/// # Example
///
/// Construct the task and its separate control handle, subscribe before
/// starting the task, then run it under the caller's Tokio runtime. A reachable
/// server is required at runtime, so this example only type-checks.
///
/// ```no_run
/// use irtt_client::{ClientConfig, ClientEvent};
/// use irtt_client::managed::{
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
/// };
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let config = ManagedClientConfig {
/// client: ClientConfig {
/// request: irtt_client::SessionRequest {
/// duration: Some(std::time::Duration::from_secs(10)),
/// ..irtt_client::SessionRequest::default()
/// },
/// ..ClientConfig::default()
/// },
/// ..ManagedClientConfig::default()
/// };
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
/// let (task, handle) = ManagedClient::task(config, targets)?;
/// let mut events = handle.subscribe()?;
/// let driver = tokio::spawn(task);
///
/// while let Ok(event) = events.recv().await {
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
/// println!("received a probe reply");
/// }
/// }
/// let outcome = driver.await?;
/// println!("run ended: {:?}", outcome.end_reason);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Default)]
pub struct ManagedClient;
impl ManagedClient {
/// Validate configuration and construct a runtime-independent task/handle pair.
pub fn task(
config: ManagedClientConfig,
targets: Vec<ManagedTargetConfig>,
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
build_task(config, targets)
}
}
#[derive(Debug)]
pub(super) struct StopSignal {
requested: AtomicBool,
update_admission: AtomicU8,
wake: watch::Sender<()>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub(super) enum UpdateAdmission {
Open,
Stopping,
Closed,
}
impl UpdateAdmission {
fn from_raw(value: u8) -> Self {
match value {
value if value == Self::Open as u8 => Self::Open,
value if value == Self::Stopping as u8 => Self::Stopping,
value if value == Self::Closed as u8 => Self::Closed,
_ => unreachable!("managed update admission stores a known state"),
}
}
}
impl StopSignal {
fn request(&self) {
self.begin_stopping();
if !self.requested.swap(true, Ordering::AcqRel) {
self.wake.send_replace(());
}
}
pub(super) fn update_admission(&self) -> UpdateAdmission {
UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
}
pub(super) fn begin_stopping(&self) {
let mut current = self.update_admission.load(Ordering::Acquire);
loop {
match UpdateAdmission::from_raw(current) {
UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
UpdateAdmission::Open as u8,
UpdateAdmission::Stopping as u8,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return,
Err(observed) => current = observed,
},
UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
}
}
}
pub(super) fn close_updates(&self) {
self.update_admission
.store(UpdateAdmission::Closed as u8, Ordering::Release);
}
pub(super) fn is_requested(&self) -> bool {
self.requested.load(Ordering::Acquire)
}
}
/// Cloneable control and observation capability for a managed task.
#[derive(Clone)]
pub struct ManagedClientHandle {
stop: Arc<StopSignal>,
commands: mpsc::Sender<ManagedCommand>,
max_live_target_generations: usize,
status: watch::Receiver<Arc<ManagedStatus>>,
events: broadcast::WeakSender<ManagedEvent>,
}
impl fmt::Debug for ManagedClientHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ManagedClientHandle")
.field("status", &self.status())
.finish_non_exhaustive()
}
}
impl ManagedClientHandle {
/// Return the latest durable immutable status snapshot.
pub fn status(&self) -> Arc<ManagedStatus> {
Arc::clone(&self.status.borrow())
}
/// Subscribe to authoritative durable latest-state observation.
///
/// The current snapshot is marked as seen when subscribing.
/// The current snapshot is immediately available through `borrow()`. Updates
/// may coalesce; this is not a lossless event log. After the task terminates,
/// the sender closes but the final snapshot remains readable. Use
/// [`Self::subscribe`] for the lossy presentation-event stream.
pub fn subscribe_status(&self) -> ManagedStatusSubscription {
let mut status = self.status.clone();
drop(status.borrow_and_update());
status
}
/// Subscribe to future lossy presentation events.
pub fn subscribe(&self) -> Result<ManagedEventSubscription, ManagedSubscribeError> {
self.events
.upgrade()
.map(|sender| sender.subscribe())
.ok_or(ManagedSubscribeError::Closed)
}
/// Request idempotent graceful stop and return a durable-status receipt.
pub fn stop(&self) -> ManagedStopReceipt {
self.stop.request();
ManagedStopReceipt::new(self.subscribe_status())
}
/// Submit the complete desired target set, replacing any previous one.
///
/// `targets` is not a delta: any live target whose id is missing from
/// `targets` is stopped and removed. A target that is still present with an
/// unchanged configuration continues undisturbed — it is not restarted and
/// does not get a new generation. A target present with a *changed*
/// configuration, or whose current generation already reached a terminal
/// state (e.g. failed, completed, or was closed by the peer), is treated as
/// a fresh start: it is retired and a new generation is created for it, even
/// if the configuration is byte-for-byte identical to the one that just
/// finished.
///
/// This call enqueues the update without waiting for queue capacity; await
/// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
/// was applied.
pub fn update_targets(
&self,
targets: Vec<ManagedTargetConfig>,
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
match self.stop.update_admission() {
UpdateAdmission::Open => {}
UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
}
if targets.len() > self.max_live_target_generations {
return Err(ManagedCommandError::TooManyTargets {
configured: targets.len(),
limit: self.max_live_target_generations,
});
}
// Observing `Open` only permits attempting submission. A successful
// `try_send` transfers command ownership to the driver. A concurrent
// stop may move admission to `Stopping` before this send, allowing the
// command to enqueue; the driver-side check immediately before
// `apply_targets` determines whether it applies or resolves as
// `Stopping`. Terminal sealing closes the receiver before draining
// accepted commands.
let (acknowledgement, receiver) = oneshot::channel();
match self.commands.try_send(ManagedCommand::UpdateTargets {
targets,
acknowledgement,
}) {
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
},
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
}
}
}
/// Receipt for a target-set transaction accepted by the driver queue.
#[must_use = "await the receipt to observe target-update application"]
pub struct ManagedCommandReceipt {
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
}
impl ManagedCommandReceipt {
/// Block until the target-set transaction is applied or rejected.
///
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
/// must not be called from an asynchronous runtime; use the [`Future`]
/// implementation there instead.
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
self.receiver
.blocking_recv()
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
}
}
impl Future for ManagedCommandReceipt {
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.receiver).poll(cx) {
Poll::Ready(Ok(result)) => Poll::Ready(result),
Poll::Ready(Err(_)) => {
Poll::Ready(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
}
Poll::Pending => Poll::Pending,
}
}
}
pub(super) enum ManagedCommand {
UpdateTargets {
targets: Vec<ManagedTargetConfig>,
acknowledgement:
oneshot::Sender<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
},
}
/// Receipt resolving once stop is durably observed or the task is terminal.
///
/// Resolves from authoritative status as soon as `stop_requested` is true or
/// the lifecycle is Completed, Failed, or Abandoned. It need not wait for
/// graceful cleanup after stop observation.
#[must_use = "await the receipt to observe durable stop acknowledgement"]
pub struct ManagedStopReceipt {
future: Pin<Box<dyn Future<Output = ()> + Send>>,
}
impl ManagedStopReceipt {
fn new(mut status: ManagedStatusSubscription) -> Self {
let future = Box::pin(async move {
while !{
let snapshot = status.borrow();
snapshot.stop_requested
|| matches!(
snapshot.lifecycle,
ManagedLifecycle::Completed
| ManagedLifecycle::Failed
| ManagedLifecycle::Abandoned
)
} {
// A closed sender retains the terminal snapshot. Never wait
// indefinitely if the channel closes.
if status.changed().await.is_err() {
break;
}
}
});
Self { future }
}
}
impl Future for ManagedStopReceipt {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.future.as_mut().poll(cx)
}
}
fn build_task(
config: ManagedClientConfig,
targets: Vec<ManagedTargetConfig>,
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
if config.event_capacity == 0 {
return Err(ManagedConfigError::ZeroEventCapacity);
}
if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
return Err(ManagedConfigError::EventCapacityTooLarge {
configured: config.event_capacity,
maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
});
}
if config.command_capacity == 0 {
return Err(ManagedConfigError::ZeroCommandCapacity);
}
if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
return Err(ManagedConfigError::CommandCapacityTooLarge {
configured: config.command_capacity,
maximum: tokio::sync::Semaphore::MAX_PERMITS,
});
}
if config.max_live_target_generations == 0 {
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
}
if Instant::now().checked_add(config.final_drain).is_none() {
return Err(ManagedConfigError::UnschedulableFinalDrain {
duration: config.final_drain,
});
}
if targets.len() > config.max_live_target_generations {
return Err(ManagedConfigError::TooManyTargets {
configured: targets.len(),
limit: config.max_live_target_generations,
});
}
if targets.is_empty() && config.completion == ManagedCompletionPolicy::FinishWhenQuiescent {
return Err(ManagedConfigError::EmptyInitialTargets);
}
let mut ids = HashSet::with_capacity(targets.len());
let mut runtimes = Vec::with_capacity(targets.len());
let mut next_generation = 1_u64;
for target in targets {
if !ids.insert(target.id.clone()) {
return Err(ManagedConfigError::DuplicateTargetId { id: target.id });
}
let generation = next_generation;
next_generation = next_generation
.checked_add(1)
.ok_or(ManagedConfigError::GenerationExhausted)?;
let mut client_config = config.client.clone();
client_config.address_family = target
.address_family
.unwrap_or(config.client.address_family);
client_config.auth = target.auth.resolve(&config.client.auth);
validate_target_config(&client_config).map_err(|source| {
ManagedConfigError::InvalidTarget {
id: target.id.clone(),
source,
}
})?;
runtimes.push(TargetRuntime {
instance: TargetInstance {
id: target.id.clone(),
generation,
},
config: target.clone(),
membership: TargetMembership::Desired,
server_addr: Arc::from(target.server_addr),
remote: None,
counters: TargetCounters::default(),
phase: Some(TargetPhase::Pending { client_config }),
});
}
let (event_sender, _) = broadcast::channel(config.event_capacity);
let weak_events = event_sender.downgrade();
let (wake_sender, wake_receiver) = watch::channel(());
let stop = Arc::new(StopSignal {
requested: AtomicBool::new(false),
update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
wake: wake_sender,
});
let history = OutcomeHistory::new(config.outcome_history_limit);
let initial_targets = runtimes
.iter()
.map(|target| ManagedTargetStatus {
target: target.instance.clone(),
desired: target.membership.is_desired(),
lifecycle: ManagedTargetLifecycle::Pending,
outcome: None,
server_addr: Arc::clone(&target.server_addr),
remote: None,
})
.collect::<Vec<_>>();
let phase = TaskPhase::NotStarted;
let initial = Arc::new(ManagedStatus {
lifecycle: phase.lifecycle(),
stop_requested: false,
applied_command_sequence: 0,
desired_target_count: runtimes.len(),
connecting_target_count: 0,
opening_target_count: 0,
active_target_count: 0,
draining_target_count: 0,
closing_target_count: 0,
terminal_target_count: 0,
total_target_outcomes: 0,
successful_target_outcomes: 0,
failed_target_outcomes: 0,
peer_closed_target_outcomes: 0,
discarded_target_outcomes: 0,
targets: Arc::from(initial_targets.into_boxed_slice()),
recent_target_outcomes: history.recent(),
final_outcome: phase.final_outcome(),
});
let (status_sender, status_receiver) = watch::channel(initial);
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
let max_live_target_generations = config.max_live_target_generations;
let resources = TaskResources {
status: status_sender,
events: Some(event_sender),
stop: Arc::clone(&stop),
};
let task = ManagedClientTask {
#[cfg(test)]
timeout_inspections: std::cell::RefCell::new(Vec::new()),
phase,
config,
targets: runtimes,
commands: command_receiver,
history,
resources: Some(resources),
wake: Some(arm_wake(wake_receiver)),
timer: None,
cursor: 0,
timeout_cursor: 0,
timeout_backlog: VecDeque::with_capacity(TIMEOUT_WORK_BUDGET),
scan_remaining: 0,
send_cursor: 0,
burst_remaining: 0,
stagger_remaining: 0,
send_gate: None,
last_stagger_send: None,
stop_observed: false,
next_generation,
applied_command_sequence: 0,
};
let handle = ManagedClientHandle {
stop,
commands: command_sender,
max_live_target_generations,
status: status_receiver,
events: weak_events,
};
Ok((task, handle))
}
pub(super) fn validate_target_config(config: &ClientConfig) -> Result<(), ClientError> {
config.open.validate()?;
SessionMachine::validate_config(config)?;
if let Some(ttl) = config.socket.ttl {
validate_ttl(ttl)?;
}
Ok(())
}
@@ -0,0 +1,365 @@
//! Burst/stagger send selection and committed-send cadence.
use std::{
task::{Context, Poll},
time::{Duration, Instant},
};
use crate::{
managed::{schedule::instant_abs_diff, ManagedTargetEndReason, ManagedTargetFailurePhase},
ClientEvent, SendProbeError,
};
use super::{
target::{OpenSessionFailureCleanup, TargetPhase},
ManagedClientTask, TARGET_WORK_BUDGET,
};
impl ManagedClientTask {
pub(super) fn active_count(&self) -> usize {
self.targets
.iter()
.filter(|target| target.is_paced_active())
.count()
}
fn active_stagger_spacing(&self) -> Option<Duration> {
let mut active = 0;
let mut minimum: Option<Duration> = None;
for target in &self.targets {
let TargetPhase::Active { schedule, .. } = target.phase() else {
continue;
};
if !target.membership.is_desired() {
continue;
}
active += 1;
minimum = minimum.into_iter().chain(Some(schedule.interval())).min();
}
minimum.map(|interval| stagger_spacing(interval, active))
}
pub(super) fn stagger_target_added(&mut self, now: Instant) {
let Some(existing) = self.send_gate.filter(|gate| *gate > now) else {
self.send_gate = None;
return;
};
let candidate = self
.last_stagger_send
.zip(self.active_stagger_spacing())
.and_then(|(last, spacing)| last.checked_add(spacing))
.filter(|gate| *gate > now);
self.send_gate = candidate.map(|candidate| existing.min(candidate));
}
pub(super) fn stagger_target_removed(&mut self, now: Instant) {
self.send_gate = self.send_gate.filter(|gate| *gate > now);
}
/// The next stagger gate after a send was accepted at `accepted_at`.
///
/// The gate advances on its **own** cadence rather than being re-anchored
/// on every observed accept time. Anchoring on `accepted_at` folds each
/// send's wakeup latency into the next slot and then into every slot after
/// it, so the gate — and with it the whole group's send cadence — drifts
/// later without bound while the per-target schedules stay on their
/// absolute grid. The drift is not hypothetical: it shows up as a
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
/// interval, as probe slots the schedule then skips.
///
/// `accepted_at` is still the anchor in the two cases where continuing the
/// old cadence would be wrong: there is no previous gate, or the pacer has
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
/// stranded in the past from releasing a burst of catch-up sends once the
/// group starts moving again.
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
let base = match self.send_gate {
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
_ => accepted_at,
};
base.checked_add(spacing)
}
fn record_stagger_acceptance(
&mut self,
result: SendResult,
stagger_spacing: Option<Duration>,
accepted_at: Instant,
) {
if !result.accepted() {
return;
}
let Some(spacing) = stagger_spacing else {
return;
};
self.last_stagger_send = Some(accepted_at);
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
}
fn poll_one_send(
&mut self,
index: usize,
cx: &mut Context<'_>,
now: Instant,
stagger_spacing: Option<Duration>,
) -> SendResult {
if !self.targets[index].membership.is_desired() {
if let TargetPhase::Active { send_waiting, .. } = self.targets[index].phase_mut() {
*send_waiting = false;
}
return SendResult::NotAttempted;
}
let phase = self.targets[index].take_phase();
let TargetPhase::Active {
mut client,
mut schedule,
mut send_waiting,
} = phase
else {
self.targets[index].restore_phase(phase);
return SendResult::NotAttempted;
};
// A burst pass may span several slots; validate this target against
// fresh time rather than the pass's earlier scheduling snapshot.
let now = now.max(Instant::now());
if client
.next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now)
{
send_waiting = false;
self.targets[index].restore_phase(TargetPhase::Active {
client,
schedule,
send_waiting,
});
return SendResult::NotAttempted;
}
if !schedule.permit_probe_at(now) {
send_waiting = false;
let pending = client.has_pending_probes();
if !pending {
self.targets[index].sync_packets_sent(&client);
}
self.targets[index].restore_phase(TargetPhase::Active {
client,
schedule,
send_waiting,
});
if pending {
return SendResult::NotAttempted;
}
self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
return SendResult::Ready { accepted: false };
}
if schedule
.next_send_deadline()
.is_none_or(|deadline| deadline > now)
{
send_waiting = false;
self.targets[index].restore_phase(TargetPhase::Active {
client,
schedule,
send_waiting,
});
return SendResult::NotAttempted;
}
let commit =
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
Ok(commit) => commit,
Err(error) => {
self.targets[index].restore_phase(TargetPhase::Active {
client,
schedule,
send_waiting,
});
self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Drain,
);
return SendResult::Failed { accepted: false };
}
};
let scheduled_at = commit.scheduled_at;
let result = client.poll_send_probe(cx);
let receipt = match &result {
Poll::Ready(Ok(receipt))
| Poll::Ready(Err(SendProbeError::AfterCommit { receipt, .. })) => Some(*receipt),
Poll::Pending | Poll::Ready(Err(SendProbeError::NotCommitted(_))) => None,
};
let accepted = receipt.is_some();
let send_result = match &result {
Poll::Pending => SendResult::Pending,
Poll::Ready(Ok(_)) => SendResult::Ready { accepted },
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
};
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
if accepted {
schedule.commit(commit);
}
let schedule_error = if accepted {
schedule.skip_missed_slots_at(Instant::now()).err()
} else {
None
};
self.targets[index].sync_packets_sent(&client);
send_waiting = result.is_pending();
self.targets[index].restore_phase(TargetPhase::Active {
client,
schedule,
send_waiting,
});
// A committed send must reach the lifecycle/accounting stream even
// when post-send processing fails. Publish before failure cleanup.
let sent_event = receipt.map(|receipt| {
let mut event = ClientEvent::from(receipt);
if let ClientEvent::EchoSent {
scheduled_at: intended,
timer_error,
..
} = &mut event
{
*intended = Some(scheduled_at);
*timer_error = Some(instant_abs_diff(receipt.sent_at.mono, scheduled_at));
}
event
});
match result {
Poll::Pending => SendResult::Pending,
Poll::Ready(Ok(_)) => {
self.publish_client_events(index, vec![sent_event.unwrap()]);
if let Some(error) = schedule_error {
self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Drain,
);
SendResult::Failed { accepted }
} else {
SendResult::Ready { accepted }
}
}
Poll::Ready(Err(error)) => {
if let Some(event) = sent_event {
self.publish_client_events(index, vec![event]);
}
let error = match error {
SendProbeError::NotCommitted(source) => source,
SendProbeError::AfterCommit { source, .. } => *source,
};
self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Sending,
error,
now,
OpenSessionFailureCleanup::Drain,
);
SendResult::Failed { accepted }
}
}
}
pub(super) fn poll_staggered_send(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
if self.active_count() == 0 || self.send_gate.is_some_and(|gate| gate > now) {
return false;
}
if self.stagger_remaining == 0 {
self.stagger_remaining = self.targets.len();
}
let work = self.stagger_remaining.min(TARGET_WORK_BUDGET);
for _ in 0..work {
let index = self.send_cursor % self.targets.len();
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
self.stagger_remaining -= 1;
if !self.targets[index].is_paced_active() {
continue;
}
let stagger_spacing = self.active_stagger_spacing();
let result = self.poll_one_send(index, cx, now, stagger_spacing);
if result == SendResult::NotAttempted {
continue;
}
self.stagger_remaining = 0;
return matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
}
self.stagger_remaining > 0
}
pub(super) fn poll_burst_sends(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
if self.targets.is_empty() {
return false;
}
if self.burst_remaining == 0 {
self.burst_remaining = self.targets.len();
}
let work = self.burst_remaining.min(TARGET_WORK_BUDGET);
let mut immediate = false;
for _ in 0..work {
let index = self.send_cursor % self.targets.len();
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
self.burst_remaining -= 1;
let result = self.poll_one_send(index, cx, now, None);
immediate |= matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
}
immediate || self.burst_remaining > 0
}
}
// A release gate delays a send opportunity; it cannot create one.
pub(super) fn gated_send_deadline(
cadence: Option<Instant>,
gate: Option<Instant>,
) -> Option<Instant> {
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum SendResult {
NotAttempted,
Pending,
Ready { accepted: bool },
Failed { accepted: bool },
}
impl SendResult {
fn accepted(self) -> bool {
matches!(
self,
Self::Ready { accepted: true } | Self::Failed { accepted: true }
)
}
}
fn stagger_spacing(interval: Duration, active_targets: usize) -> Duration {
let divisor = u128::try_from(active_targets.max(1)).unwrap_or(u128::MAX);
let nanos = (interval.as_nanos() / divisor).max(1);
let seconds = u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX);
let subsec = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
Duration::new(seconds, subsec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_gate_only_delays_an_existing_send_deadline() {
let earlier = Instant::now();
let later = earlier + Duration::from_secs(1);
for (cadence, gate, expected) in [
(None, None, None),
(None, Some(earlier), None),
(None, Some(later), None),
(Some(earlier), None, Some(earlier)),
(Some(later), Some(earlier), Some(later)),
(Some(earlier), Some(earlier), Some(earlier)),
(Some(earlier), Some(later), Some(later)),
] {
assert_eq!(gated_send_deadline(cadence, gate), expected);
}
}
}
@@ -0,0 +1,780 @@
//! Target-generation state, phase transitions, accounting, and completion.
use std::{
future::Future,
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Instant,
};
use crate::{
async_client::{AsyncClient, AsyncOpenState},
managed::schedule::ProbeSchedule,
managed::{
classify_client_error, ManagedEvent, ManagedTargetConfig, ManagedTargetEndReason,
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase,
ManagedTargetLifecycle, ManagedTargetOutcome, TargetInstance,
},
ClientConfig, ClientError, ClientEvent, OpenOutcome,
};
use super::{ManagedClientTask, TARGET_WORK_BUDGET};
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
type ConnectFuture =
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
pub(super) enum TargetPhase {
Pending {
client_config: ClientConfig,
},
Connecting {
future: ConnectFuture,
},
Opening {
client: AsyncClient,
open: Box<AsyncOpenState>,
},
Active {
client: AsyncClient,
schedule: ProbeSchedule,
send_waiting: bool,
},
Draining {
client: AsyncClient,
drain_started_at: Instant,
deadline: Instant,
primary_end: ManagedTargetEndReason,
cleanup_failure: Option<ManagedTargetFailure>,
post_deadline_receives_remaining: usize,
},
Closing {
client: AsyncClient,
deadline: Instant,
primary_end: ManagedTargetEndReason,
cleanup_failure: Option<ManagedTargetFailure>,
},
/// Terminal state always owns its finalized authoritative outcome.
Terminal {
outcome: Arc<ManagedTargetOutcome>,
},
}
impl TargetPhase {
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
match self {
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
Self::Active { .. } => ManagedTargetLifecycle::Active,
Self::Draining { .. } => ManagedTargetLifecycle::Draining,
Self::Closing { .. } => ManagedTargetLifecycle::Closing,
Self::Terminal { .. } => ManagedTargetLifecycle::Terminal,
}
}
pub(super) fn client(&self) -> &AsyncClient {
match self {
Self::Opening { client, .. }
| Self::Active { client, .. }
| Self::Draining { client, .. }
| Self::Closing { client, .. } => client,
_ => panic!("managed target has no client in this phase"),
}
}
fn into_client(self) -> AsyncClient {
match self {
Self::Opening { client, .. }
| Self::Active { client, .. }
| Self::Draining { client, .. }
| Self::Closing { client, .. } => client,
_ => panic!("managed target has no client in this phase"),
}
}
}
#[derive(Default)]
pub(super) struct TargetCounters {
packets_sent: u64,
/// Unique echo replies that arrived in order and before their probe timed
/// out.
///
/// A reply that arrives behind an already-received sequence, or after its
/// own probe timed out, is counted by `late` and deliberately not here.
/// These counters are durable, unlike the lossy managed presentation
/// events.
replies_received: u64,
duplicates: u64,
late: u64,
warning_events: u64,
}
impl TargetCounters {
/// Account for one client event.
///
/// Events that carry no durable target-local count are ignored. Probe
/// sends are not counted here: `packets_sent` is authoritative in the
/// underlying client and is synchronized through
/// [`TargetRuntime::sync_packets_sent`].
fn observe(&mut self, event: &ClientEvent) {
match event {
ClientEvent::EchoReply { .. } => {
self.replies_received = self.replies_received.saturating_add(1);
}
ClientEvent::DuplicateReply { .. } => {
self.duplicates = self.duplicates.saturating_add(1);
}
ClientEvent::LateReply { .. } => {
self.late = self.late.saturating_add(1);
}
ClientEvent::Warning { .. } => {
self.warning_events = self.warning_events.saturating_add(1);
}
ClientEvent::EchoSent { .. }
| ClientEvent::EchoLoss { .. }
| ClientEvent::SessionStarted(_)
| ClientEvent::NoTestCompleted(_)
| ClientEvent::SessionClosed { .. } => {}
}
}
}
#[derive(Clone, Copy)]
pub(super) enum RetirementReason {
Removed,
Replaced,
}
impl From<RetirementReason> for ManagedTargetEndReason {
fn from(reason: RetirementReason) -> Self {
match reason {
RetirementReason::Removed => Self::Removed,
RetirementReason::Replaced => Self::Replaced,
}
}
}
pub(super) enum TargetMembership {
Desired,
Withdrawn(RetirementReason),
}
impl TargetMembership {
pub(super) fn is_desired(&self) -> bool {
matches!(self, Self::Desired)
}
fn retirement_reason(&self) -> Option<RetirementReason> {
match self {
Self::Desired => None,
Self::Withdrawn(reason) => Some(*reason),
}
}
}
pub(super) struct TargetRuntime {
pub(super) instance: TargetInstance,
pub(super) config: ManagedTargetConfig,
pub(super) membership: TargetMembership,
pub(super) server_addr: Arc<str>,
pub(super) remote: Option<std::net::SocketAddr>,
pub(super) counters: TargetCounters,
// None only while a synchronous operation owns the phase locally.
pub(super) phase: Option<TargetPhase>,
}
impl TargetRuntime {
pub(super) fn phase(&self) -> &TargetPhase {
self.phase
.as_ref()
.expect("managed target phase is extracted")
}
pub(super) fn phase_mut(&mut self) -> &mut TargetPhase {
self.phase
.as_mut()
.expect("managed target phase is extracted")
}
pub(super) fn take_phase(&mut self) -> TargetPhase {
self.phase
.take()
.expect("managed target phase is extracted")
}
pub(super) fn restore_phase(&mut self, phase: TargetPhase) {
assert!(
self.phase.is_none(),
"managed target phase is already installed"
);
self.phase = Some(phase);
}
pub(super) fn is_paced_active(&self) -> bool {
let phase = self.phase();
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
}
/// Adopt the underlying client's authoritative sent count.
///
/// The client owns `packets_sent`; this runtime only mirrors it so a
/// terminal outcome can report it after the client is gone.
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
self.counters.packets_sent = client.packets_sent();
}
pub(super) fn sync_live_packets_sent(&mut self) {
self.counters.packets_sent = self.phase().client().packets_sent();
}
/// Build this target's durable outcome.
pub(super) fn outcome(
&self,
end_reason: ManagedTargetEndReason,
cleanup_failure: Option<ManagedTargetFailure>,
) -> ManagedTargetOutcome {
ManagedTargetOutcome {
target: self.instance.clone(),
server_addr: Arc::clone(&self.server_addr),
remote: self.remote,
end_reason,
packets_sent: self.counters.packets_sent,
replies_received: self.counters.replies_received,
duplicates: self.counters.duplicates,
late: self.counters.late,
warning_events: self.counters.warning_events,
cleanup_failure,
}
}
}
#[derive(Clone, Copy)]
pub(super) enum OpenSessionFailureCleanup {
Drain,
Close,
}
impl ManagedClientTask {
pub(super) fn install_target_phase(&mut self, index: usize, phase: TargetPhase) {
let previous = self.targets[index].phase().lifecycle();
self.targets[index].phase = Some(phase);
self.publish_target_phase(index, previous);
}
fn publish_target_phase(&mut self, index: usize, previous: ManagedTargetLifecycle) {
let lifecycle = self.targets[index].phase().lifecycle();
let desired = self.targets[index].membership.is_desired();
let was_paced_active = desired && previous == ManagedTargetLifecycle::Active;
let paced_active = self.targets[index].is_paced_active();
let now = Instant::now();
match (was_paced_active, paced_active) {
(false, true) => self.stagger_target_added(now),
(true, false) => self.stagger_target_removed(now),
_ => {}
}
self.replace_status();
self.publish_event(ManagedEvent::TargetStateChanged {
target: self.targets[index].instance.clone(),
lifecycle,
});
}
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
for event in events {
self.targets[index].counters.observe(&event);
self.publish_event(ManagedEvent::Client {
target: self.targets[index].instance.clone(),
event,
});
}
}
fn finish_target(
&mut self,
index: usize,
end_reason: ManagedTargetEndReason,
cleanup_failure: Option<ManagedTargetFailure>,
) {
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
// Finalize history before the phase publisher installs Terminal, updates
// pacing and publishes one complete status followed by the lifecycle event.
self.history.record(outcome.as_ref().clone());
self.install_target_phase(
index,
TargetPhase::Terminal {
outcome: Arc::clone(&outcome),
},
);
self.publish_event(ManagedEvent::TargetFinished { outcome });
}
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
let failure = classify_client_error(phase, &error);
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
}
pub(super) fn begin_open_session_failure(
&mut self,
index: usize,
phase: ManagedTargetFailurePhase,
error: ClientError,
now: Instant,
cleanup: OpenSessionFailureCleanup,
) -> bool {
let primary_end = ManagedTargetEndReason::Failed(classify_client_error(phase, &error));
let (TargetPhase::Opening { client, .. } | TargetPhase::Active { client, .. }) =
self.targets[index].phase_mut()
else {
unreachable!("open-session failure requires an opening or active target")
};
client.discard_prepared_probe();
self.targets[index].sync_live_packets_sent();
match cleanup {
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
}
}
pub(super) fn effective_retirement(&self, index: usize) -> Option<ManagedTargetEndReason> {
self.targets[index]
.membership
.retirement_reason()
.map(Into::into)
}
fn start_connecting(&mut self, index: usize) {
let TargetPhase::Pending { client_config } = self.targets[index].take_phase() else {
unreachable!("connecting starts from a pending target")
};
let endpoint = Arc::clone(&self.targets[index].server_addr);
let future = Box::pin(async move { AsyncClient::connect(endpoint, client_config).await });
self.targets[index].restore_phase(TargetPhase::Connecting { future });
self.publish_target_phase(index, ManagedTargetLifecycle::Pending);
}
pub(super) fn poll_target(&mut self, index: usize, cx: &mut Context<'_>, now: Instant) -> bool {
let retirement = self.effective_retirement(index);
let stopping = self.phase.is_stopping() || retirement.is_some();
match self.targets[index].phase_mut() {
TargetPhase::Pending { .. } => {
if stopping {
self.finish_target(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
None,
);
false
} else {
self.start_connecting(index);
true
}
}
TargetPhase::Connecting { future } => {
if stopping {
self.finish_target(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
None,
);
return false;
}
match future.as_mut().poll(cx) {
Poll::Pending => false,
Poll::Ready(Ok(client)) => {
self.targets[index].remote = Some(client.remote_addr());
self.install_target_phase(
index,
TargetPhase::Opening {
client,
open: Box::new(AsyncOpenState::new()),
},
);
true
}
Poll::Ready(Err(error)) => {
self.fail_target(index, ManagedTargetFailurePhase::Connecting, error);
false
}
}
}
TargetPhase::Opening { client, open } => {
if stopping {
let retirement = retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped);
if !open.has_in_flight_work() {
self.targets[index].sync_live_packets_sent();
self.finish_target(index, retirement, None);
return false;
}
open.request_stop_after_current_attempt();
}
match client.poll_open(open, cx) {
Poll::Pending => false,
Poll::Ready(Ok(OpenOutcome::Started(started))) => {
let schedule = match ProbeSchedule::new(
started.at.mono,
&started.negotiation.accepted,
) {
Ok(schedule) => schedule,
Err(error) => {
return self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Close,
)
}
};
self.publish_client_events(
index,
vec![ClientEvent::SessionStarted(started)],
);
if stopping {
self.targets[index].sync_live_packets_sent();
self.begin_drain(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
now,
)
} else {
let previous = self.targets[index].take_phase();
self.targets[index].restore_phase(TargetPhase::Active {
client: previous.into_client(),
schedule,
send_waiting: false,
});
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
true
}
}
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
self.publish_client_events(
index,
vec![ClientEvent::NoTestCompleted(completed)],
);
let end_reason = if stopping {
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped)
} else {
ManagedTargetEndReason::NoTestComplete
};
self.finish_target(index, end_reason, None);
false
}
Poll::Ready(Err(error)) => {
if stopping {
let TargetPhase::Opening { open, .. } = self.targets[index].phase()
else {
unreachable!()
};
let cleanup_failure =
(!open.stopped_after_current_attempt()).then(|| {
classify_client_error(
ManagedTargetFailurePhase::Opening,
&error,
)
});
self.finish_target(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
cleanup_failure,
);
} else {
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
}
false
}
}
}
TargetPhase::Active { client, .. } => {
if stopping {
client.discard_prepared_probe();
self.targets[index].sync_live_packets_sent();
return self.begin_drain(
index,
retirement
.clone()
.unwrap_or(ManagedTargetEndReason::Stopped),
now,
);
}
if client
.next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now)
{
return true;
}
let received = match client.poll_recv(cx) {
Poll::Pending => false,
Poll::Ready(Ok(events)) => {
self.publish_client_events(index, events);
true
}
Poll::Ready(Err(error)) => {
return self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Receiving,
error,
now,
OpenSessionFailureCleanup::Close,
);
}
};
if self.targets[index].phase().client().is_peer_closed() {
self.targets[index].sync_live_packets_sent();
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
return false;
}
let TargetPhase::Active {
client, schedule, ..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
schedule.permit_probe_at(now);
if schedule.is_finished() && !client.has_pending_probes() {
self.targets[index].sync_live_packets_sent();
return self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
}
received
}
TargetPhase::Draining { client, .. } => {
if client
.next_probe_timeout_deadline()
.is_some_and(|timeout| timeout <= now)
{
return true;
}
let mut retained_state_changed = false;
let received = match client.poll_recv(cx) {
Poll::Pending => false,
Poll::Ready(Ok(events)) => {
self.publish_client_events(index, events);
retained_state_changed = true;
true
}
Poll::Ready(Err(error)) => {
self.targets[index].sync_live_packets_sent();
let TargetPhase::Draining {
primary_end,
cleanup_failure,
..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
cleanup_failure.get_or_insert_with(|| {
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
});
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
return self.begin_close(index, primary_end, cleanup_failure, now);
}
};
if self.targets[index].phase().client().is_peer_closed() {
self.targets[index].sync_live_packets_sent();
let TargetPhase::Draining {
cleanup_failure, ..
} = self.targets[index].phase()
else {
unreachable!()
};
self.finish_target(
index,
ManagedTargetEndReason::PeerClosed,
cleanup_failure.clone(),
);
return false;
}
if retained_state_changed {
let TargetPhase::Draining {
client,
drain_started_at,
..
} = self.targets[index].phase()
else {
unreachable!()
};
let candidate = self.drain_deadline(client, *drain_started_at);
let TargetPhase::Draining {
deadline,
primary_end,
cleanup_failure,
..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
match candidate {
Some(candidate) => *deadline = (*deadline).min(candidate),
None => {
cleanup_failure.get_or_insert_with(duration_overflow_failure);
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
return self.begin_close(index, primary_end, cleanup_failure, now);
}
}
}
let TargetPhase::Draining {
deadline,
primary_end,
cleanup_failure,
post_deadline_receives_remaining,
..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
if now >= *deadline {
if received && *post_deadline_receives_remaining > 1 {
*post_deadline_receives_remaining -= 1;
true
} else {
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
self.begin_close(index, primary_end, cleanup_failure, now)
}
} else {
received
}
}
TargetPhase::Closing { client, .. } => {
let result = client.poll_close(cx);
let TargetPhase::Closing {
deadline,
primary_end,
cleanup_failure,
..
} = self.targets[index].phase()
else {
unreachable!()
};
let deadline = *deadline;
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
match result {
Poll::Pending => {
if now >= deadline {
self.targets[index].sync_live_packets_sent();
self.finish_target(
index,
primary_end,
cleanup_failure.or_else(|| Some(close_timeout_failure())),
);
}
false
}
Poll::Ready(Ok(events)) => {
self.targets[index].sync_live_packets_sent();
self.publish_client_events(index, events);
self.finish_target(index, primary_end, cleanup_failure);
false
}
Poll::Ready(Err(error)) => {
self.targets[index].sync_live_packets_sent();
let cleanup =
classify_client_error(ManagedTargetFailurePhase::Closing, &error);
self.finish_target(index, primary_end, cleanup_failure.or(Some(cleanup)));
false
}
}
}
TargetPhase::Terminal { .. } => false,
}
}
pub(super) fn begin_drain(
&mut self,
index: usize,
primary_end: ManagedTargetEndReason,
_now: Instant,
) -> bool {
let drain_started_at = Instant::now();
let Some(deadline) =
self.drain_deadline(self.targets[index].phase().client(), drain_started_at)
else {
return self.begin_close(
index,
primary_end,
Some(duration_overflow_failure()),
drain_started_at,
);
};
let previous = self.targets[index].take_phase();
let lifecycle = previous.lifecycle();
self.targets[index].restore_phase(TargetPhase::Draining {
client: previous.into_client(),
drain_started_at,
deadline,
primary_end,
cleanup_failure: None,
post_deadline_receives_remaining: POST_DEADLINE_RECEIVE_BUDGET,
});
self.publish_target_phase(index, lifecycle);
true
}
pub(super) fn drain_deadline(
&self,
client: &AsyncClient,
drain_started_at: Instant,
) -> Option<Instant> {
client
.latest_probe_timeout_deadline()
.unwrap_or(drain_started_at)
.max(drain_started_at)
.checked_add(self.config.final_drain)
}
pub(super) fn begin_close(
&mut self,
index: usize,
primary_end: ManagedTargetEndReason,
mut cleanup_failure: Option<ManagedTargetFailure>,
now: Instant,
) -> bool {
let deadline = now.checked_add(self.config.final_drain).unwrap_or_else(|| {
cleanup_failure.get_or_insert_with(duration_overflow_failure);
now
});
let previous = self.targets[index].take_phase();
let lifecycle = previous.lifecycle();
self.targets[index].restore_phase(TargetPhase::Closing {
client: previous.into_client(),
deadline,
primary_end,
cleanup_failure,
});
self.publish_target_phase(index, lifecycle);
true
}
}
pub(super) fn duration_overflow_failure() -> ManagedTargetFailure {
classify_client_error(
ManagedTargetFailurePhase::Timing,
&ClientError::DurationOverflow,
)
}
fn close_timeout_failure() -> ManagedTargetFailure {
ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Closing,
kind: ManagedTargetFailureKind::Timeout,
message: Arc::from("best-effort close did not become writable before its deadline"),
}
}
@@ -0,0 +1,226 @@
//! Bounded timeout discovery and processing across target generations.
use std::time::Instant;
use crate::managed::{classify_client_error, ManagedTargetFailurePhase};
use super::{
target::{duration_overflow_failure, OpenSessionFailureCleanup, TargetPhase},
ManagedClientTask,
};
pub(super) const TIMEOUT_WORK_BUDGET: usize = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct TimeoutBacklogEntry {
index: usize,
generation: u64,
}
#[derive(Clone, Copy)]
struct TimeoutStep {
more_due: bool,
}
impl TimeoutStep {
const NONE: Self = Self { more_due: false };
}
impl ManagedClientTask {
pub(super) fn poll_timeout_pass(&mut self, now: Instant) -> bool {
let target_len = self.targets.len();
if target_len == 0 {
return false;
}
let mut transitions_remaining = TIMEOUT_WORK_BUDGET;
for _ in 0..target_len.min(TIMEOUT_WORK_BUDGET) {
if transitions_remaining == 0 {
break;
}
let index = self.timeout_cursor;
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
if self.target_has_due_timeout(index, now) {
let generation = self.targets[index].instance.generation;
let step = self.poll_target_timeout(index, now);
transitions_remaining -= 1;
if step.more_due {
self.enqueue_timeout_backlog(index, generation);
}
}
}
while transitions_remaining > 0 {
let Some(entry) = self.timeout_backlog.pop_front() else {
break;
};
let valid = self
.targets
.get(entry.index)
.is_some_and(|target| target.instance.generation == entry.generation)
&& self.target_has_due_timeout(entry.index, now);
if !valid {
continue;
}
let step = self.poll_target_timeout(entry.index, now);
transitions_remaining -= 1;
if step.more_due {
self.enqueue_timeout_backlog(entry.index, entry.generation);
}
}
!self.timeout_backlog.is_empty()
}
fn enqueue_timeout_backlog(&mut self, index: usize, generation: u64) {
if self.timeout_backlog.len() == TIMEOUT_WORK_BUDGET
|| self
.timeout_backlog
.iter()
.any(|entry| entry.index == index && entry.generation == generation)
{
return;
}
self.timeout_backlog
.push_back(TimeoutBacklogEntry { index, generation });
}
fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool {
#[cfg(test)]
self.timeout_inspections.borrow_mut().push(index);
match self.targets[index].phase() {
TargetPhase::Active { client, .. } => {
!self.phase.is_stopping()
&& self.effective_retirement(index).is_none()
&& client
.next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now)
}
TargetPhase::Draining { client, .. } => client
.next_probe_timeout_deadline()
.is_some_and(|timeout| timeout <= now),
_ => false,
}
}
fn poll_target_timeout(&mut self, index: usize, now: Instant) -> TimeoutStep {
match self.targets[index].phase_mut() {
TargetPhase::Active { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
Ok(batch) => {
self.publish_client_events(index, batch.events);
TimeoutStep {
more_due: batch.more_due,
}
}
Err(error) => {
self.begin_open_session_failure(
index,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Close,
);
TimeoutStep::NONE
}
},
TargetPhase::Draining { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
Ok(batch) => {
self.publish_client_events(index, batch.events);
let TargetPhase::Draining {
client,
drain_started_at,
..
} = self.targets[index].phase()
else {
unreachable!()
};
let candidate = self.drain_deadline(client, *drain_started_at);
let TargetPhase::Draining {
deadline,
primary_end,
cleanup_failure,
..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
match candidate {
Some(candidate) => *deadline = (*deadline).min(candidate),
None => {
cleanup_failure.get_or_insert_with(duration_overflow_failure);
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
self.begin_close(index, primary_end, cleanup_failure, now);
return TimeoutStep::NONE;
}
}
TimeoutStep {
more_due: batch.more_due,
}
}
Err(error) => {
self.targets[index].sync_live_packets_sent();
let TargetPhase::Draining {
primary_end,
cleanup_failure,
..
} = self.targets[index].phase_mut()
else {
unreachable!()
};
cleanup_failure.get_or_insert_with(|| {
classify_client_error(ManagedTargetFailurePhase::Timing, &error)
});
let primary_end = primary_end.clone();
let cleanup_failure = cleanup_failure.clone();
self.begin_close(index, primary_end, cleanup_failure, now);
TimeoutStep::NONE
}
},
_ => TimeoutStep::NONE,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::managed::{ManagedClientConfig, ManagedTargetConfig};
use super::{super::ManagedClient, Instant, TIMEOUT_WORK_BUDGET};
// Poll latency through public events cannot distinguish an O(n) discovery
// scan from a bounded scan reliably. This tiny inspection trace measures
// work directly, including empty targets, and checks eventual coverage
// without asserting a cursor value or a particular visitation order.
#[test]
fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() {
let count = TIMEOUT_WORK_BUDGET * 2 + 1;
let targets = (0..count)
.map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112"))
.collect();
let (mut task, _handle) = ManagedClient::task(
ManagedClientConfig {
max_live_target_generations: count,
..ManagedClientConfig::default()
},
targets,
)
.unwrap();
let mut visited = HashSet::new();
for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) {
task.timeout_inspections.borrow_mut().clear();
task.poll_timeout_pass(Instant::now());
let inspected = task.timeout_inspections.borrow();
assert!(inspected.len() <= TIMEOUT_WORK_BUDGET);
visited.extend(inspected.iter().copied());
}
assert_eq!(
visited.len(),
count,
"timeout discovery must reach all targets"
);
}
}
@@ -0,0 +1,311 @@
//! Atomic desired-target transactions, generation retirement, and pruning.
use std::{
collections::HashSet,
sync::Arc,
task::{Context, Poll},
time::Instant,
};
use crate::{
managed::{
ManagedCommandAcknowledgement, ManagedCommandApplyError, ManagedCompletionPolicy,
ManagedEvent, ManagedTargetConfig, ManagedTargetLifecycle, TargetInstance,
},
ClientConfig,
};
use super::{
control::{validate_target_config, ManagedCommand, UpdateAdmission},
target::{RetirementReason, TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
ManagedClientTask, TaskPhase,
};
const COMMAND_WORK_BUDGET: usize = 32;
struct PlannedTarget {
target: ManagedTargetConfig,
client_config: ClientConfig,
generation: u64,
}
struct PlannedRetirement {
index: usize,
reason: RetirementReason,
synchronous: bool,
}
struct UpdatePlan {
retirements: Vec<PlannedRetirement>,
created: Vec<PlannedTarget>,
next_generation: u64,
next_command_sequence: u64,
prospective_live_count: usize,
}
fn synchronously_retireable(phase: &TargetPhase) -> bool {
match phase {
TargetPhase::Pending { .. }
| TargetPhase::Connecting { .. }
| TargetPhase::Terminal { .. } => true,
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
TargetPhase::Active { .. } | TargetPhase::Draining { .. } | TargetPhase::Closing { .. } => {
false
}
}
}
fn runtime_satisfies_target(runtime: &TargetRuntime, target: &ManagedTargetConfig) -> bool {
runtime.membership.is_desired()
&& !matches!(runtime.phase(), TargetPhase::Terminal { .. })
&& runtime.config == *target
}
impl ManagedClientTask {
pub(super) fn process_commands(&mut self, cx: &mut Context<'_>) -> bool {
let mut immediate = false;
for _ in 0..COMMAND_WORK_BUDGET {
match self.commands.poll_recv(cx) {
Poll::Ready(Some(ManagedCommand::UpdateTargets {
targets,
acknowledgement,
})) => {
// This check is the driver-side application linearization point.
// `apply_targets` cannot suspend, so a stop observed here wins over
// this command; once it passes, this command may complete atomically.
let result = if !self.phase.is_running()
|| self.resources().stop.update_admission() != UpdateAdmission::Open
{
Err(ManagedCommandApplyError::Stopping)
} else {
self.apply_targets(targets)
};
let _ = acknowledgement.send(result);
immediate = true;
}
Poll::Ready(None) | Poll::Pending => break,
}
}
immediate
}
fn apply_targets(
&mut self,
incoming: Vec<ManagedTargetConfig>,
) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
let plan = self.plan_targets(incoming)?;
debug_assert!(plan.prospective_live_count <= self.config.max_live_target_generations);
let now = Instant::now();
let mut synchronous = HashSet::with_capacity(plan.retirements.len());
let mut finished = Vec::new();
for retirement in &plan.retirements {
let target = &mut self.targets[retirement.index];
let was_paced_active = target.is_paced_active();
target.membership = TargetMembership::Withdrawn(retirement.reason);
if retirement.synchronous {
synchronous.insert(retirement.index);
if !matches!(target.phase(), TargetPhase::Terminal { .. }) {
let outcome = Arc::new(target.outcome(retirement.reason.into(), None));
self.history.record(outcome.as_ref().clone());
finished.push(ManagedEvent::TargetFinished { outcome });
}
} else if was_paced_active {
// Removal is directional: it may discard an elapsed gate but never
// lengthens an existing future stagger gate.
self.stagger_target_removed(now);
}
}
if !synchronous.is_empty() {
let mut index = 0;
self.targets.retain(|_| {
let keep = !synchronous.contains(&index);
index += 1;
keep
});
self.rebase_target_cursors();
}
let mut created_instances = Vec::with_capacity(plan.created.len());
for planned in plan.created {
let instance = TargetInstance {
id: planned.target.id.clone(),
generation: planned.generation,
};
created_instances.push(instance.clone());
self.targets.push(TargetRuntime {
instance,
server_addr: Arc::from(planned.target.server_addr.clone()),
config: planned.target,
membership: TargetMembership::Desired,
remote: None,
counters: TargetCounters::default(),
phase: Some(TargetPhase::Pending {
client_config: planned.client_config,
}),
});
}
self.next_generation = plan.next_generation;
self.applied_command_sequence = plan.next_command_sequence;
let stopping = self
.targets
.iter()
.all(|runtime| !runtime.membership.is_desired())
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
if stopping {
self.resources().stop.begin_stopping();
self.phase = TaskPhase::Stopping;
}
// A transaction has one externally visible durable point: all runtime and
// history changes above, then this exact status snapshot, then its events and
// acknowledgement. Do not use per-transition publishing helpers here.
let status = self.snapshot();
self.resources().status.send_replace(Arc::clone(&status));
if stopping {
self.publish_event(ManagedEvent::Stopping);
}
for event in finished {
self.publish_event(event);
}
for target in created_instances {
self.publish_event(ManagedEvent::TargetStateChanged {
target,
lifecycle: ManagedTargetLifecycle::Pending,
});
}
Ok(ManagedCommandAcknowledgement {
sequence: self.applied_command_sequence,
status,
})
}
fn plan_targets(
&self,
incoming: Vec<ManagedTargetConfig>,
) -> Result<UpdatePlan, ManagedCommandApplyError> {
let mut ids = HashSet::with_capacity(incoming.len());
let mut prepared = Vec::with_capacity(incoming.len());
for target in incoming {
if !ids.insert(target.id.clone()) {
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
}
let mut client_config = self.config.client.clone();
client_config.address_family = target
.address_family
.unwrap_or(self.config.client.address_family);
client_config.auth = target.auth.resolve(&self.config.client.auth);
validate_target_config(&client_config).map_err(|source| {
ManagedCommandApplyError::InvalidTarget {
id: target.id.clone(),
source,
}
})?;
prepared.push((target, client_config));
}
let mut retirements = Vec::new();
for (index, runtime) in self.targets.iter().enumerate() {
if !runtime.membership.is_desired()
|| prepared
.iter()
.any(|(target, _)| runtime_satisfies_target(runtime, target))
{
continue;
}
let reason = if prepared
.iter()
.any(|(target, _)| target.id == runtime.instance.id)
{
RetirementReason::Replaced
} else {
RetirementReason::Removed
};
retirements.push(PlannedRetirement {
index,
reason,
synchronous: synchronously_retireable(runtime.phase()),
});
}
let mut next_generation = self.next_generation;
let mut created = Vec::new();
for (target, client_config) in prepared {
if self
.targets
.iter()
.any(|runtime| runtime_satisfies_target(runtime, &target))
{
continue;
}
let generation = next_generation;
next_generation = next_generation
.checked_add(1)
.ok_or(ManagedCommandApplyError::GenerationExhausted)?;
created.push(PlannedTarget {
target,
client_config,
generation,
});
}
let next_command_sequence = self
.applied_command_sequence
.checked_add(1)
.ok_or(ManagedCommandApplyError::CommandSequenceExhausted)?;
let synchronous = retirements
.iter()
.filter(|retirement| retirement.synchronous)
.map(|retirement| retirement.index)
.collect::<HashSet<_>>();
let prospective_live_count = self
.targets
.iter()
.enumerate()
.filter(|(index, _)| !synchronous.contains(index))
.count()
.saturating_add(created.len());
if prospective_live_count > self.config.max_live_target_generations {
return Err(ManagedCommandApplyError::LiveGenerationLimitExceeded {
required: prospective_live_count,
limit: self.config.max_live_target_generations,
});
}
Ok(UpdatePlan {
retirements,
created,
next_generation,
next_command_sequence,
prospective_live_count,
})
}
pub(super) fn prune_undesired_terminal(&mut self) {
if !self.targets.iter().any(|target| {
!target.membership.is_desired()
&& matches!(target.phase(), TargetPhase::Terminal { .. })
}) {
return;
}
self.targets.retain(|target| {
target.membership.is_desired()
|| !matches!(target.phase(), TargetPhase::Terminal { .. })
});
self.rebase_target_cursors();
self.replace_status();
}
fn rebase_target_cursors(&mut self) {
let len = self.targets.len();
if len == 0 {
self.cursor = 0;
self.timeout_cursor = 0;
self.send_cursor = 0;
} else {
self.cursor %= len;
self.timeout_cursor %= len;
self.send_cursor %= len;
}
self.scan_remaining = self.scan_remaining.min(len);
self.burst_remaining = self.burst_remaining.min(len);
self.stagger_remaining = self.stagger_remaining.min(len);
}
}
+105 -35
View File
@@ -242,63 +242,61 @@ impl PendingMap {
#[derive(Debug)]
pub(crate) struct TimedOutMap {
map: HashMap<u32, PendingProbe>,
insertion_order: VecDeque<u32>,
max_capacity: usize,
// Timeout transitions consume PendingMap's head in deadline order. Reuse
// its linked storage in a separate map: the head owns FIFO eviction and
// the tail owns the latest retained timeout, even after arbitrary replies.
probes: PendingMap,
#[cfg(all(test, feature = "tokio"))]
deadline_inspections: std::cell::Cell<usize>,
}
impl TimedOutMap {
pub fn new(max_capacity: usize) -> Self {
Self {
map: HashMap::new(),
insertion_order: VecDeque::new(),
max_capacity,
probes: PendingMap::new(max_capacity),
#[cfg(all(test, feature = "tokio"))]
deadline_inspections: std::cell::Cell::new(0),
}
}
pub fn insert(&mut self, probe: PendingProbe) {
if self.max_capacity == 0 {
if self.probes.max_capacity == 0 {
return;
}
if let std::collections::hash_map::Entry::Occupied(mut entry) =
self.map.entry(probe.wire_seq)
{
entry.insert(probe);
return;
// A sequence cannot be both pending and retained: committing its next
// generation removes the old retention before adding the pending probe.
debug_assert!(!self.probes.map.contains_key(&probe.wire_seq));
if self.probes.map.len() == self.probes.max_capacity {
self.probes
.remove(self.probes.first.expect("full retention has a head"));
}
while self.map.len() >= self.max_capacity {
self.evict_oldest();
}
self.insertion_order.push_back(probe.wire_seq);
self.map.insert(probe.wire_seq, probe);
// Unlike pending admission, retention always makes room by eviction.
// Allocation here remains infallible as it was for the previous map.
self.probes.commit_insert(probe);
}
pub fn remove(&mut self, wire_seq: u32) -> Option<PendingProbe> {
let removed = self.map.remove(&wire_seq);
if removed.is_some() {
self.insertion_order.retain(|seq| *seq != wire_seq);
}
removed
self.probes.remove(wire_seq)
}
/// Mutable access to a still-retained timed-out probe by wire sequence,
/// without disturbing eviction order. Used to attach an observed kernel
/// TX timestamp that arrives after the probe has already timed out.
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
self.map.get_mut(&wire_seq)
self.probes.get_mut(wire_seq)
}
#[cfg(feature = "tokio")]
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
self.map.values().map(|probe| probe.timeout_at).max()
}
fn evict_oldest(&mut self) {
while let Some(oldest_key) = self.insertion_order.pop_front() {
if self.map.remove(&oldest_key).is_some() {
break;
}
}
self.probes
.last
.and_then(|wire_seq| self.probes.map.get(&wire_seq))
.map(|entry| {
#[cfg(test)]
self.deadline_inspections
.set(self.deadline_inspections.get() + 1);
entry.probe.timeout_at
})
}
}
@@ -427,17 +425,89 @@ mod tests {
}
}
// Measure deadline inspection work directly: wall-clock thresholds cannot
// reliably distinguish a bounded lookup from a retention scan.
#[cfg(feature = "tokio")]
#[test]
fn timeout_retention_deadlines_have_bounded_lookup_work() {
const COUNT: u32 = 4096;
let start = Instant::now();
let deadline = |seq| start + Duration::from_micros(u64::from(seq / 2));
let mut pending_map = PendingMap::new(COUNT as usize);
let mut timed_out = TimedOutMap::new(COUNT as usize);
for seq in 0..COUNT {
pending_map.preflight_insert(seq).unwrap();
pending_map.commit_insert(pending(seq, deadline(seq)));
}
let last_deadline = deadline(COUNT - 1);
for seq in 0..COUNT {
let batch = pending_map.drain_expired_bounded(last_deadline, 1);
assert_eq!(batch.probes.len(), 1);
assert_eq!(batch.probes[0].wire_seq, seq);
assert_eq!(batch.more_due, seq + 1 < COUNT);
timed_out.insert(batch.probes.into_iter().next().unwrap());
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
assert_eq!(
pending_map
.latest_timeout_deadline()
.into_iter()
.chain(timed_out.latest_timeout_deadline())
.max(),
Some(last_deadline)
);
}
assert!(
timed_out.deadline_inspections.replace(0) <= 2 * COUNT as usize,
"draining must not scan growing timeout retention after each transition"
);
// A middle/head removal leaves the latest deadline intact. Removing
// the newest survivors must walk back through equal deadlines too.
assert!(timed_out.remove(COUNT / 2).is_some());
assert!(timed_out.remove(0).is_some());
for seq in (1..COUNT).rev().filter(|seq| *seq != COUNT / 2) {
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
assert!(timed_out.remove(seq).is_some());
}
assert_eq!(timed_out.latest_timeout_deadline(), None);
assert!(timed_out.deadline_inspections.get() <= COUNT as usize);
}
// Public loss/reply events cannot reveal leaked retention bookkeeping.
#[test]
fn timed_out_retention_stays_bounded_after_repeated_removals() {
fn timed_out_retention_preserves_fifo_eviction_and_stays_bounded() {
let mut map = TimedOutMap::new(4);
let now = Instant::now();
for i in 0..20 {
map.insert(pending(i, now));
assert!(map.remove(i).is_some());
assert!(map.map.len() <= 4);
assert!(map.insertion_order.len() <= 4);
assert!(map.probes.map.len() <= 4);
}
// Equal deadlines across wire-sequence wrap must still evict by
// insertion order. Removal at any position frees capacity immediately.
let sequences = [u32::MAX - 1, u32::MAX, 0, 1];
for removed in sequences {
let mut map = TimedOutMap::new(4);
for seq in sequences {
map.insert(pending(seq, now));
}
assert!(map.remove(removed).is_some());
map.insert(pending(2, now));
let oldest = sequences.into_iter().find(|seq| *seq != removed).unwrap();
map.insert(pending(3, now));
assert!(map.remove(oldest).is_none(), "oldest survivor is evicted");
for seq in sequences
.into_iter()
.filter(|seq| *seq != removed && *seq != oldest)
{
assert!(map.remove(seq).is_some());
}
assert!(map.remove(2).is_some());
assert!(map.remove(3).is_some());
assert!(map.probes.map.is_empty());
#[cfg(feature = "tokio")]
assert_eq!(map.latest_timeout_deadline(), None);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::{error::Error, fmt, net::SocketAddr, time::Duration};
use crate::{ClientError, ClientEvent, ClientTimestamp};
/// An Echo datagram accepted by the UDP socket and committed in session state.
///
/// The wire sequence and sent count have advanced, and the probe is pending.
/// This receipt remains authoritative even if subsequent validation or kernel
/// TX timestamp processing fails. It carries transport facts only; managed
/// scheduling annotations belong on the corresponding [`ClientEvent::EchoSent`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SendReceipt {
/// Committed wire sequence number.
pub seq: u32,
/// Resolved remote socket address.
pub remote: SocketAddr,
/// Public measurement timestamp sampled immediately after socket acceptance.
/// The private pre-send anchor still determines the timeout deadline.
pub sent_at: ClientTimestamp,
/// Number of bytes the socket reported accepting.
pub bytes: usize,
/// Time spent in the successful socket send call.
pub send_call: Duration,
}
impl From<SendReceipt> for ClientEvent {
/// Construct the committed send event without scheduling annotations.
fn from(receipt: SendReceipt) -> Self {
Self::EchoSent {
seq: receipt.seq,
remote: receipt.remote,
scheduled_at: None,
sent_at: receipt.sent_at,
bytes: receipt.bytes,
send_call: receipt.send_call,
timer_error: None,
}
}
}
/// Probe-send failure with an explicit socket-acceptance commitment boundary.
///
/// There is deliberately no blanket conversion from [`ClientError`]: each send
/// failure must be classified at the point where its commitment is known.
#[derive(Debug)]
pub enum SendProbeError {
/// No datagram was accepted and no probe state or sequence was committed.
NotCommitted(ClientError),
/// The probe was committed, then post-send processing failed.
/// The pending probe and advanced sequence/count remain committed.
AfterCommit {
/// Authoritative receipt for the accepted send.
receipt: SendReceipt,
/// Operational cause of the post-send failure.
source: Box<ClientError>,
},
}
impl fmt::Display for SendProbeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotCommitted(source) => write!(f, "probe send was not committed: {source}"),
Self::AfterCommit { source, .. } => {
write!(
f,
"probe send was committed, but post-send processing failed: {source}"
)
}
}
}
}
impl Error for SendProbeError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(match self {
Self::NotCommitted(source) => source,
Self::AfterCommit { source, .. } => source.as_ref(),
})
}
}
+143 -73
View File
@@ -7,16 +7,17 @@ use irtt_proto::{
use crate::{
config::{
ClientConfig, RunMode, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_UDP_PAYLOAD_LENGTH,
ClientConfig, RunMode, SessionRequest, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES,
MAX_UDP_PAYLOAD_LENGTH,
},
error::ClientError,
event::{
ClientEvent, OneWayDelaySample, OpenOutcome, ReceivedStatsSample, RttSample, ServerTiming,
SignedDuration, WarningKind,
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, ReceivedStatsSample,
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
},
metadata::ReceiveMeta,
probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
session::{negotiate_params, NegotiatedParams},
session::{negotiate_params, NegotiationResult},
socket_options::dscp_codepoint_to_traffic_class,
timing::ClientTimestamp,
};
@@ -52,7 +53,7 @@ enum CloseSource {
#[derive(Debug)]
struct ActiveSession {
token: u64,
negotiated: NegotiatedParams,
negotiated: NegotiationResult,
local_close_packet: Box<[u8]>,
next_wire_seq: u32,
highest_received_seq: Option<u32>,
@@ -81,7 +82,7 @@ pub(crate) struct PreparedOpenAcceptance {
}
impl PreparedOpenAcceptance {
pub(crate) fn normal_negotiated(&self) -> Option<&NegotiatedParams> {
pub(crate) fn normal_negotiated(&self) -> Option<&NegotiationResult> {
match &self.next_state {
MachineState::Open(session) => Some(&session.negotiated),
_ => None,
@@ -172,7 +173,7 @@ impl SessionMachine {
remote: std::net::SocketAddr,
) -> Result<Self, ClientError> {
Self::validate_config(&config)?;
let requested = params_from_config(&config)?;
let requested = params_from_request(&config.request)?;
Ok(Self {
config,
@@ -193,7 +194,7 @@ impl SessionMachine {
reason: "probe_timeout must be greater than zero".to_owned(),
});
}
params_from_config(config).map(|_| ())
params_from_request(&config.request).map(|_| ())
}
pub(crate) fn config(&self) -> &ClientConfig {
@@ -201,7 +202,7 @@ impl SessionMachine {
}
pub(crate) fn has_hmac(&self) -> bool {
self.config.hmac_key.is_some()
self.config.auth.hmac_key().is_some()
}
pub(crate) fn prepare_open_request(&self) -> Result<PreparedOpenRequest, ClientError> {
@@ -209,9 +210,9 @@ impl SessionMachine {
let bytes = encode_request(
RequestToEncode::Open {
params: &self.requested,
no_test: self.config.run_mode == RunMode::NoTest,
no_test: self.config.request.run_mode == RunMode::NoTest,
},
self.config.hmac_key.as_deref(),
self.config.auth.hmac_key(),
)?;
Ok(PreparedOpenRequest {
bytes: bytes.into_boxed_slice(),
@@ -223,7 +224,7 @@ impl SessionMachine {
packet: &[u8],
) -> Result<OpenDatagramDisposition, ClientError> {
self.ensure_connected()?;
match irtt_proto::decode_open_reply(packet, self.config.hmac_key.as_deref()) {
match irtt_proto::decode_open_reply(packet, self.config.auth.hmac_key()) {
Ok(reply) => Ok(OpenDatagramDisposition::Trusted(reply)),
Err(error @ irtt_proto::ProtoError::ZeroToken) => Err(ClientError::Protocol(error)),
Err(
@@ -250,7 +251,7 @@ impl SessionMachine {
let cleanup_close = if !reply_is_close && reply.token != 0 {
let bytes = encode_request(
RequestToEncode::Close { token: reply.token },
self.config.hmac_key.as_deref(),
self.config.auth.hmac_key(),
)
.map_err(ClientError::from)
.map_err(OpenAcceptanceFailure::without_cleanup)?;
@@ -269,7 +270,7 @@ impl SessionMachine {
));
}
match self.config.run_mode {
match self.config.request.run_mode {
RunMode::Normal if reply_is_close => Err(OpenAcceptanceFailure::new(
ClientError::ServerRejected,
cleanup_close,
@@ -310,10 +311,10 @@ impl SessionMachine {
RequestToEncode::Echo {
token: session.token,
sequence: session.next_wire_seq,
params: &session.negotiated.params,
params: &session.negotiated.peer_params,
payload: &[],
},
self.config.hmac_key.as_deref(),
self.config.auth.hmac_key(),
)?;
Ok(PreparedProbe {
bytes: bytes.into_boxed_slice(),
@@ -539,7 +540,7 @@ impl SessionMachine {
}
}
pub(crate) fn negotiated_params(&self) -> Option<&NegotiatedParams> {
pub(crate) fn negotiation(&self) -> Option<&NegotiationResult> {
match &self.state {
MachineState::Open(session) => Some(&session.negotiated),
_ => None,
@@ -591,7 +592,6 @@ impl SessionMachine {
}
}
#[cfg(feature = "tokio")]
pub(crate) fn ensure_open(&self) -> Result<(), ClientError> {
self.open_session().map(|_| ())
}
@@ -603,14 +603,11 @@ impl SessionMachine {
cleanup_close: Option<Box<[u8]>>,
) -> Result<PreparedOpenAcceptance, OpenAcceptanceFailure> {
let token = reply.token;
let negotiated = match negotiate_params(
&self.requested,
reply.params,
self.config.negotiation_policy,
) {
Ok(negotiated) => negotiated,
Err(primary) => return Err(OpenAcceptanceFailure::new(primary, cleanup_close)),
};
let negotiated =
match negotiate_params(&self.requested, reply.params, self.config.open.negotiation) {
Ok(negotiated) => negotiated,
Err(primary) => return Err(OpenAcceptanceFailure::new(primary, cleanup_close)),
};
let local_close_packet =
cleanup_close.expect("normal non-zero-token replies prepare a cleanup close");
let next_state = MachineState::Open(Box::new(ActiveSession {
@@ -626,19 +623,12 @@ impl SessionMachine {
kernel_tx_correlation_valid: true,
}));
let event = ClientEvent::SessionStarted {
let outcome = OpenOutcome::Started(SessionStarted {
remote: self.remote,
token,
negotiated: negotiated.clone(),
negotiation: negotiated,
at: now,
};
let outcome = OpenOutcome::Started {
remote: self.remote,
token,
negotiated,
event,
};
});
Ok(PreparedOpenAcceptance {
next_state,
outcome,
@@ -650,22 +640,14 @@ impl SessionMachine {
reply: OpenReply,
now: ClientTimestamp,
) -> Result<PreparedOpenAcceptance, OpenAcceptanceFailure> {
let negotiated = negotiate_params(
&self.requested,
reply.params,
self.config.negotiation_policy,
)
.map_err(OpenAcceptanceFailure::without_cleanup)?;
let event = ClientEvent::NoTestCompleted {
let negotiated =
negotiate_params(&self.requested, reply.params, self.config.open.negotiation)
.map_err(OpenAcceptanceFailure::without_cleanup)?;
let outcome = OpenOutcome::NoTestCompleted(NoTestCompleted {
remote: self.remote,
negotiated: negotiated.clone(),
negotiation: negotiated,
at: now,
};
let outcome = OpenOutcome::NoTestCompleted {
remote: self.remote,
negotiated,
event,
};
});
Ok(PreparedOpenAcceptance {
next_state: MachineState::NoTestCompleted,
outcome,
@@ -679,8 +661,8 @@ impl SessionMachine {
decode_echo_reply(
packet,
&session.negotiated.params,
self.config.hmac_key.as_deref(),
&session.negotiated.peer_params,
self.config.auth.hmac_key(),
)
.ok()
}
@@ -879,31 +861,34 @@ impl SessionMachine {
/// rather than asserted away so that no 64-bit assumption is baked in.
pub(crate) fn recv_buffer_size(
has_hmac: bool,
negotiated: Option<&NegotiatedParams>,
negotiated: Option<&NegotiationResult>,
) -> Result<usize, ClientError> {
Ok(match negotiated {
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.params)?
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.peer_params)?
.saturating_add(1)
.max(MIN_RECV_BUFFER_SIZE),
None => MIN_RECV_BUFFER_SIZE,
})
}
pub(crate) fn params_from_config(config: &ClientConfig) -> Result<Params, ClientError> {
validate_protocol_config(config)?;
pub(crate) fn params_from_request(request: &SessionRequest) -> Result<Params, ClientError> {
validate_request(request)?;
Ok(Params {
protocol_version: PROTOCOL_VERSION,
duration_ns: match config.duration {
duration_ns: match request.duration {
Some(duration) => config_duration_to_ns("duration", duration)?,
None => 0,
},
interval_ns: config_duration_to_ns("interval", config.interval)?,
length: i64::from(config.length),
received_stats: config.received_stats,
stamp_at: config.stamp_at,
clock: config.clock,
dscp: i64::from(dscp_codepoint_to_traffic_class(config.dscp)?),
server_fill: config.server_fill.clone().map(|value| ServerFill { value }),
interval_ns: config_duration_to_ns("interval", request.interval)?,
length: i64::from(request.length),
received_stats: request.received_stats,
stamp_at: request.stamp_at,
clock: request.clock,
dscp: i64::from(dscp_codepoint_to_traffic_class(request.dscp)?),
server_fill: request
.server_fill
.clone()
.map(|value| ServerFill { value }),
})
}
@@ -1084,34 +1069,34 @@ fn build_received_stats(reply: &EchoReply) -> Option<ReceivedStatsSample> {
})
}
fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
if config.duration == Some(Duration::ZERO) {
fn validate_request(request: &SessionRequest) -> Result<(), ClientError> {
if request.duration == Some(Duration::ZERO) {
return Err(ClientError::InvalidConfig {
reason: "duration must be greater than zero; use None for continuous mode".to_owned(),
});
}
if config.interval == Duration::ZERO {
if request.interval == Duration::ZERO {
return Err(ClientError::InvalidConfig {
reason: "interval must be greater than zero".to_owned(),
});
}
if config.clock == Clock::Unspecified {
if request.clock == Clock::Unspecified {
return Err(ClientError::InvalidConfig {
reason: "clock must be wall, monotonic, or both".to_owned(),
});
}
if config.dscp > MAX_DSCP_CODEPOINT {
if request.dscp > MAX_DSCP_CODEPOINT {
return Err(ClientError::InvalidConfig {
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
});
}
if config.length > MAX_UDP_PAYLOAD_LENGTH {
if request.length > MAX_UDP_PAYLOAD_LENGTH {
return Err(ClientError::InvalidConfig {
reason: format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"),
});
}
if let Some(fill) = &config.server_fill {
if let Some(fill) = &request.server_fill {
let len = fill.len();
if len == 0 {
return Err(ClientError::InvalidConfig {
@@ -1192,9 +1177,12 @@ mod tests {
fn opened() -> SessionMachine {
let mut machine = SessionMachine::new(
ClientConfig {
request: crate::SessionRequest {
clock: Clock::Wall,
stamp_at: irtt_proto::StampAt::Receive,
..Default::default()
},
max_pending_probes: 2,
clock: Clock::Wall,
stamp_at: irtt_proto::StampAt::Receive,
..ClientConfig::default()
},
"127.0.0.1:2112".parse().unwrap(),
@@ -1246,6 +1234,86 @@ mod tests {
events.into_iter().next().unwrap()
}
// The latest retained deadline is internal to managed draining. Exercise
// ordinary session transitions to check its classification/retention policy.
#[cfg(feature = "tokio")]
#[test]
fn retained_deadline_tracks_timeouts_late_replies_eviction_and_close() {
let mut machine = opened();
assert_eq!(send(&mut machine).seq, 0);
let first = machine.latest_probe_timeout_deadline().unwrap();
assert_eq!(send(&mut machine).seq, 1);
let second = machine.latest_probe_timeout_deadline().unwrap();
let batch = machine.poll_timeouts_bounded_at(second, 1).unwrap();
assert!(
matches!(batch.events[0], ClientEvent::EchoLoss { seq: 0, timeout_at, .. } if timeout_at == first)
);
assert!(batch.more_due);
assert_eq!(machine.latest_probe_timeout_deadline(), Some(second));
let batch = machine.poll_timeouts_bounded_at(second, 1).unwrap();
assert!(
matches!(batch.events[0], ClientEvent::EchoLoss { seq: 1, timeout_at, .. } if timeout_at == second)
);
assert!(!batch.more_due);
assert!(machine.pending_is_empty());
assert_eq!(machine.next_probe_timeout_deadline(), None);
assert_eq!(machine.latest_probe_timeout_deadline(), Some(second));
assert!(matches!(
reply(&mut machine, 1),
ClientEvent::LateReply {
sent_at: Some(_),
rtt: Some(_),
..
}
));
assert_eq!(machine.latest_probe_timeout_deadline(), Some(first));
assert!(matches!(
reply(&mut machine, 1),
ClientEvent::DuplicateReply { .. }
));
for seq in 2..4 {
assert_eq!(send(&mut machine).seq, seq);
let deadline = machine.latest_probe_timeout_deadline().unwrap();
machine.poll_timeouts_at(deadline).unwrap();
assert_eq!(machine.latest_probe_timeout_deadline(), Some(deadline));
}
// Capacity eviction removes seq 0's measurement, not late classification.
assert!(matches!(
reply(&mut machine, 0),
ClientEvent::LateReply {
sent_at: None,
rtt: None,
..
}
));
assert!(matches!(
reply(&mut machine, 3),
ClientEvent::LateReply {
sent_at: Some(_),
rtt: Some(_),
..
}
));
assert!(machine.latest_probe_timeout_deadline().is_some());
assert!(matches!(
reply(&mut machine, 2),
ClientEvent::LateReply {
sent_at: Some(_),
rtt: Some(_),
..
}
));
assert_eq!(machine.latest_probe_timeout_deadline(), None);
assert_eq!(send(&mut machine).seq, 4);
let deadline = machine.next_probe_timeout_deadline().unwrap();
machine.poll_timeouts_at(deadline).unwrap();
let commit = machine.prepare_close().unwrap().commit;
machine.commit_local_close(commit, ClientTimestamp::now());
assert_eq!(machine.latest_probe_timeout_deadline(), None);
}
// Advancing a real client through 2^32 submissions is unreasonable. Only
// move the send/receive sequence epoch; construct completion/timeout history through
// ordinary machine transitions and assert emitted behavior on reuse.
@@ -1279,6 +1347,8 @@ mod tests {
reply(&mut machine, 0),
ClientEvent::EchoReply { seq: 0, .. }
));
#[cfg(feature = "tokio")]
assert_eq!(machine.latest_probe_timeout_deadline(), None);
assert!(matches!(
reply(&mut machine, 0),
ClientEvent::DuplicateReply { seq: 0, .. }
+1 -1
View File
@@ -2,4 +2,4 @@ pub(crate) mod machine;
mod negotiation;
pub(crate) use negotiation::negotiate_params;
pub use negotiation::{NegotiatedParams, NegotiationRestriction};
pub use negotiation::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
+157 -107
View File
@@ -1,117 +1,139 @@
use std::fmt;
use std::{fmt, time::Duration};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
use crate::{config::NegotiationPolicy, error::ClientError};
/// Protocol parameters accepted for a session.
/// Validated session semantics accepted after a successful Open exchange.
///
/// `params` contains the server-returned values that the client will use for
/// echo packets. `restrictions` records accepted differences from the request
/// when loose negotiation is enabled.
///
/// `params.dscp` is the raw IP TOS / Traffic Class byte, not a DSCP
/// codepoint: for a configured codepoint of 46 (EF), an unrestricted
/// negotiation leaves `params.dscp == 184`. [`NegotiationRestriction::DscpChanged`]
/// reports codepoints instead, for consistency with [`crate::ClientConfig::dscp`].
/// These values drive ordinary client behavior. The exact peer-returned wire
/// representation is available separately in [`NegotiationResult::peer_params`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedParams {
/// Server-returned protocol parameters used for the session.
pub params: Params,
/// Accepted server restrictions or parameter changes.
pub restrictions: Vec<NegotiationRestriction>,
pub struct AcceptedSessionParameters {
/// `None` means continuous mode; finite durations are positive.
pub duration: Option<Duration>,
/// Positive interval between probes when driven by a scheduler.
pub interval: Duration,
/// Accepted packet length in the usable unsigned domain.
pub length: u32,
pub received_stats: ReceivedStats,
pub stamp_at: StampAt,
pub clock: Clock,
/// DSCP codepoint (`0..=63`), as in [`crate::SessionRequest::dscp`].
pub dscp: u8,
/// Accepted server payload fill, preserving the peer's string value.
pub server_fill: Option<String>,
}
/// A server-side restriction applied during session parameter negotiation.
/// Accepted session semantics, exact protocol evidence, and accepted changes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NegotiationRestriction {
/// Run duration was reduced.
pub struct NegotiationResult {
pub accepted: AcceptedSessionParameters,
/// Exact decoded parameters returned by the peer, without normalization.
///
/// A requested duration of `0` means the client requested continuous mode.
/// When `requested_ns == 0` and `negotiated_ns > 0`, the server limited
/// that continuous request to a finite duration.
/// Echo encoding/decoding uses these accepted wire values. `dscp` here is
/// the raw IP TOS / Traffic Class byte, unlike [`AcceptedSessionParameters::dscp`].
pub peer_params: Params,
/// Differences accepted under [`crate::NegotiationPolicy::Loose`].
pub changes: Vec<NegotiationChange>,
}
/// A semantic parameter change accepted during negotiation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NegotiationChange {
/// A finite duration was reduced, or continuous mode was limited.
DurationReduced {
requested_ns: i64,
negotiated_ns: i64,
/// `None` means the request was continuous.
requested: Option<Duration>,
negotiated: Duration,
},
/// Probe interval was increased.
IntervalIncreased {
requested_ns: i64,
negotiated_ns: i64,
requested: Duration,
negotiated: Duration,
},
/// Probe interval was reduced.
/// Loose policy accepts any positive reduced interval.
IntervalReduced {
requested_ns: i64,
negotiated_ns: i64,
requested: Duration,
negotiated: Duration,
},
LengthReduced {
requested: u32,
negotiated: u32,
},
/// Packet length was reduced.
LengthReduced { requested: i64, negotiated: i64 },
/// Returned received-statistics mode differs from the request.
ReceivedStatsChanged {
requested: ReceivedStats,
negotiated: ReceivedStats,
},
/// Returned timestamp placement differs from the request.
StampAtChanged {
requested: StampAt,
negotiated: StampAt,
},
/// Returned clock source differs from the request.
ClockChanged { requested: Clock, negotiated: Clock },
/// Returned DSCP codepoint differs from the request.
///
/// These values are DSCP codepoints (`0..=63`) for human-facing
/// consistency with [`crate::ClientConfig::dscp`], not the raw wire
/// `Params::dscp` byte carried by [`NegotiatedParams::params`].
DscpChanged { requested: i64, negotiated: i64 },
/// Returned server payload fill behavior differs from the request.
ServerFillChanged,
ClockChanged {
requested: Clock,
negotiated: Clock,
},
/// Both values are DSCP codepoints, rather than raw Traffic Class bytes.
DscpChanged {
requested: u8,
negotiated: u8,
},
ServerFillChanged {
requested: Option<String>,
negotiated: Option<String>,
},
}
impl NegotiationRestriction {
/// Return a human-readable description of this negotiated restriction.
impl NegotiationChange {
/// Return a human-readable description of this accepted change.
pub fn message(&self) -> String {
self.to_string()
}
}
impl fmt::Display for NegotiationRestriction {
impl fmt::Display for NegotiationChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DurationReduced {
requested_ns: 0,
negotiated_ns,
requested: None,
negotiated,
} => {
write!(
f,
"server limited continuous duration to {negotiated_ns} ns"
"server limited continuous duration to {} ns",
negotiated.as_nanos()
)
}
Self::DurationReduced {
requested_ns,
negotiated_ns,
requested: Some(requested),
negotiated,
} => {
write!(
f,
"server reduced duration from {requested_ns} ns to {negotiated_ns} ns"
"server reduced duration from {} ns to {} ns",
requested.as_nanos(),
negotiated.as_nanos()
)
}
Self::IntervalIncreased {
requested_ns,
negotiated_ns,
requested,
negotiated,
} => {
write!(
f,
"server increased interval from {requested_ns} ns to {negotiated_ns} ns"
"server increased interval from {} ns to {} ns",
requested.as_nanos(),
negotiated.as_nanos()
)
}
Self::IntervalReduced {
requested_ns,
negotiated_ns,
requested,
negotiated,
} => {
write!(
f,
"server reduced interval from {requested_ns} ns to {negotiated_ns} ns"
"server reduced interval from {} ns to {} ns",
requested.as_nanos(),
negotiated.as_nanos()
)
}
Self::LengthReduced {
@@ -156,23 +178,32 @@ impl fmt::Display for NegotiationRestriction {
} => {
write!(f, "server changed DSCP from {requested} to {negotiated}")
}
Self::ServerFillChanged => write!(f, "server changed payload fill behavior"),
Self::ServerFillChanged { .. } => write!(f, "server changed payload fill behavior"),
}
}
}
// Called only after validating the request and returned numeric domains.
fn positive_duration(nanos: i64) -> Duration {
Duration::from_nanos(u64::try_from(nanos).expect("validated non-negative nanoseconds"))
}
fn finite_duration(nanos: i64) -> Option<Duration> {
(nanos != 0).then(|| positive_duration(nanos))
}
pub(crate) fn negotiate_params(
requested: &Params,
returned: Params,
policy: NegotiationPolicy,
) -> Result<NegotiatedParams, ClientError> {
) -> Result<NegotiationResult, ClientError> {
if returned.protocol_version != PROTOCOL_VERSION {
return Err(ClientError::ProtocolVersionMismatch {
requested: PROTOCOL_VERSION,
received: returned.protocol_version,
});
}
let mut restrictions = Vec::new();
let mut changes = Vec::new();
validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
if returned.length < 0 {
@@ -195,70 +226,70 @@ pub(crate) fn negotiate_params(
if returned.duration_ns < requested.duration_ns
|| (requested.duration_ns == 0 && returned.duration_ns > 0)
{
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
&mut changes,
NegotiationChange::DurationReduced {
requested: finite_duration(requested.duration_ns),
negotiated: positive_duration(returned.duration_ns),
},
)?;
}
if returned.interval_ns > requested.interval_ns {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::IntervalIncreased {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
&mut changes,
NegotiationChange::IntervalIncreased {
requested: positive_duration(requested.interval_ns),
negotiated: positive_duration(returned.interval_ns),
},
)?;
}
if returned.interval_ns < requested.interval_ns {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::IntervalReduced {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
&mut changes,
NegotiationChange::IntervalReduced {
requested: positive_duration(requested.interval_ns),
negotiated: positive_duration(returned.interval_ns),
},
)?;
}
if returned.length < requested.length {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
&mut changes,
NegotiationChange::LengthReduced {
requested: u32::try_from(requested.length).expect("validated requested length"),
negotiated: u32::try_from(returned.length).expect("validated returned length"),
},
)?;
}
if returned.received_stats != requested.received_stats {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::ReceivedStatsChanged {
&mut changes,
NegotiationChange::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
},
)?;
}
if returned.stamp_at != requested.stamp_at {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::StampAtChanged {
&mut changes,
NegotiationChange::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
},
)?;
}
if returned.clock != requested.clock {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::ClockChanged {
&mut changes,
NegotiationChange::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
},
@@ -270,29 +301,48 @@ pub(crate) fn negotiate_params(
});
}
if returned.dscp == 0 && requested.dscp != 0 {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::DscpChanged {
&mut changes,
NegotiationChange::DscpChanged {
// `requested`/`negotiated` here are raw wire TOS/Traffic Class
// bytes; shift back to the codepoint the user actually
// configured for a human-facing restriction.
requested: requested.dscp >> 2,
negotiated: returned.dscp >> 2,
// configured for a semantic change record.
requested: u8::try_from(requested.dscp >> 2).expect("validated requested DSCP"),
negotiated: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
},
)?;
}
if returned.server_fill != requested.server_fill {
record_restriction(
record_change(
policy,
&mut restrictions,
NegotiationRestriction::ServerFillChanged,
&mut changes,
NegotiationChange::ServerFillChanged {
requested: requested
.server_fill
.as_ref()
.map(|fill| fill.value.clone()),
negotiated: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
},
)?;
}
Ok(NegotiatedParams {
params: returned,
restrictions,
// Compare the exact wire values above before deriving accepted semantics.
// In particular, do not normalize fill or traffic class before Strict policy.
let accepted = AcceptedSessionParameters {
duration: finite_duration(returned.duration_ns),
interval: positive_duration(returned.interval_ns),
length: u32::try_from(returned.length).expect("validated returned length"),
received_stats: returned.received_stats,
stamp_at: returned.stamp_at,
clock: returned.clock,
dscp: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
server_fill: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
};
Ok(NegotiationResult {
accepted,
peer_params: returned,
changes,
})
}
@@ -332,17 +382,17 @@ fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
Ok(())
}
fn record_restriction(
fn record_change(
policy: NegotiationPolicy,
restrictions: &mut Vec<NegotiationRestriction>,
restriction: NegotiationRestriction,
changes: &mut Vec<NegotiationChange>,
change: NegotiationChange,
) -> Result<(), ClientError> {
if policy == NegotiationPolicy::Strict {
return Err(ClientError::NegotiationRejected {
reason: restriction.message(),
reason: change.message(),
});
}
restrictions.push(restriction);
changes.push(change);
Ok(())
}
+27 -43
View File
@@ -1,12 +1,9 @@
use std::{
net::{SocketAddr, ToSocketAddrs, UdpSocket},
time::Duration,
};
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use socket2::{Domain, Protocol, Socket, Type};
use crate::{
config::{ClientConfig, SocketConfig, DEFAULT_PORT, MIN_OPEN_TIMEOUT},
config::{AddressFamily, SocketConfig, DEFAULT_PORT},
error::ClientError,
receive::configure_receive_metadata,
socket_options::apply_ttl_to_socket,
@@ -20,36 +17,27 @@ use crate::{
))]
use crate::socket_options::apply_routing_options;
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
if timeouts.is_empty() {
return Err(ClientError::NoOpenTimeouts);
}
for timeout in timeouts {
if *timeout < MIN_OPEN_TIMEOUT {
return Err(ClientError::OpenTimeoutTooSmall {
timeout: *timeout,
minimum: MIN_OPEN_TIMEOUT,
});
}
}
Ok(())
}
pub(crate) fn resolve_remote(config: &ClientConfig) -> Result<SocketAddr, ClientError> {
let addr = normalize_server_addr(&config.server_addr);
pub(crate) fn resolve_remote(
endpoint: &str,
family: AddressFamily,
) -> Result<SocketAddr, ClientError> {
let addr = normalize_server_addr(endpoint);
let mut addrs = addr
.to_socket_addrs()
.map_err(|_| ClientError::Resolve { addr: addr.clone() })?;
addrs
.find(|addr| address_family_allowed(config, *addr))
.find(|addr| address_family_allowed(family, *addr))
.ok_or(ClientError::Resolve { addr })
}
#[cfg(feature = "tokio")]
pub(crate) async fn resolve_remote_tokio(config: &ClientConfig) -> Result<SocketAddr, ClientError> {
let addr = normalize_server_addr(&config.server_addr);
pub(crate) async fn resolve_remote_tokio(
endpoint: &str,
family: AddressFamily,
) -> Result<SocketAddr, ClientError> {
let addr = normalize_server_addr(endpoint);
if let Ok(remote) = addr.parse::<SocketAddr>() {
return address_family_allowed(config, remote)
return address_family_allowed(family, remote)
.then_some(remote)
.ok_or(ClientError::Resolve { addr });
}
@@ -58,13 +46,16 @@ pub(crate) async fn resolve_remote_tokio(config: &ClientConfig) -> Result<Socket
.await
.map_err(|_| ClientError::Resolve { addr: addr.clone() })?;
addrs
.find(|remote| address_family_allowed(config, *remote))
.find(|remote| address_family_allowed(family, *remote))
.ok_or_else(|| ClientError::Resolve { addr: addr.clone() })
}
fn address_family_allowed(config: &ClientConfig, remote: SocketAddr) -> bool {
(!config.socket_config.ipv4_only || remote.is_ipv4())
&& (!config.socket_config.ipv6_only || remote.is_ipv6())
fn address_family_allowed(family: AddressFamily, remote: SocketAddr) -> bool {
match family {
AddressFamily::Any => true,
AddressFamily::Ipv4 => remote.is_ipv4(),
AddressFamily::Ipv6 => remote.is_ipv6(),
}
}
pub(crate) fn normalize_server_addr(addr: &str) -> String {
@@ -92,17 +83,9 @@ pub(crate) fn normalize_server_addr(addr: &str) -> String {
pub(crate) fn connect_udp_socket(
config: &SocketConfig,
remote: SocketAddr,
family: AddressFamily,
) -> Result<UdpSocket, ClientError> {
let socket = create_connected_udp_socket(config, remote)?;
socket.set_read_timeout(config.recv_timeout)?;
Ok(socket)
}
fn create_connected_udp_socket(
config: &SocketConfig,
remote: SocketAddr,
) -> Result<UdpSocket, ClientError> {
let socket = create_prebind_udp_socket(config, remote)?;
let socket = create_prebind_udp_socket(remote, family)?;
#[cfg(any(
target_os = "android",
target_os = "freebsd",
@@ -133,8 +116,8 @@ fn create_connected_udp_socket(
}
fn create_prebind_udp_socket(
config: &SocketConfig,
remote: SocketAddr,
family: AddressFamily,
) -> Result<Socket, ClientError> {
let domain = if remote.is_ipv4() {
Domain::IPV4
@@ -143,7 +126,7 @@ fn create_prebind_udp_socket(
};
let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
if config.ipv6_only && remote.is_ipv6() {
if family == AddressFamily::Ipv6 && remote.is_ipv6() {
socket.set_only_v6(true)?;
}
Ok(socket)
@@ -153,8 +136,9 @@ fn create_prebind_udp_socket(
pub(crate) fn connect_tokio_udp_socket(
config: &SocketConfig,
remote: SocketAddr,
family: AddressFamily,
) -> Result<tokio::net::UdpSocket, ClientError> {
let socket = create_connected_udp_socket(config, remote)?;
let socket = connect_udp_socket(config, remote, family)?;
socket.set_nonblocking(true)?;
Ok(tokio::net::UdpSocket::from_std(socket)?)
}
+81 -59
View File
@@ -1,7 +1,7 @@
use super::*;
#[test]
fn open_fails_after_close() {
fn operations_fail_after_local_close_without_datagrams() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -9,10 +9,29 @@ fn open_fails_after_close() {
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
let results = [
client.recv_once(),
client.recv_available(RecvBudget { max_packets: 1 }),
];
assert!(
matches!(
results,
[
Err(ClientError::AlreadyClosed),
Err(ClientError::AlreadyClosed)
]
),
"unexpected receive results: {results:?}"
);
server.join();
}
@@ -36,12 +55,14 @@ fn send_probe_fails_after_close() {
}
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
Err(irtt_client::SendProbeError::NotCommitted(
ClientError::AlreadyClosed
))
));
server.join();
}
@@ -73,7 +94,7 @@ fn recv_available_stops_after_peer_close() {
socket.send_to(&close, peer).unwrap();
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -121,14 +142,10 @@ fn close_flagged_duplicate_emits_duplicate_then_closes() {
socket.send_to(&close, peer).unwrap();
}
});
let mut client = Client::connect(ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
})
.unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -178,20 +195,20 @@ fn close_flagged_retained_timeout_emits_late_then_closes() {
.unwrap();
}
});
let mut client = Client::connect(ClientConfig {
probe_timeout: Duration::from_millis(50),
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
let mut client = Client::connect(
server.addr.to_string(),
ClientConfig {
probe_timeout: Duration::from_millis(50),
..default_test_config()
},
..default_test_config(server.addr)
})
)
.unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
let sent = client.send_probe().unwrap();
let ClientEvent::EchoSent { sent_at, .. } = &sent[0] else {
panic!("expected EchoSent");
};
let sent_at = sent.sent_at;
assert!(matches!(
client
.poll_timeouts_at(sent_at.mono + client.probe_timeout())
@@ -273,15 +290,17 @@ fn close_flagged_evicted_sequence_emits_late_then_closes() {
.unwrap();
}
});
let mut client = Client::connect(ClientConfig {
max_pending_probes: 1,
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
let mut client = Client::connect(
server.addr.to_string(),
ClientConfig {
max_pending_probes: 1,
..default_test_config()
},
..default_test_config(server.addr)
})
)
.unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -338,14 +357,10 @@ fn close_flagged_untracked_sequence_emits_warning_then_closes() {
.unwrap();
}
});
let mut client = Client::connect(ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
})
.unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
assert!(matches!(
@@ -396,14 +411,10 @@ fn wrong_token_close_flag_does_not_close_session() {
let _ = recv_request(&socket, &tx);
}
});
let mut client = Client::connect(ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
})
.unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -415,10 +426,7 @@ fn wrong_token_close_flag_does_not_close_session() {
}]
));
assert!(!client.is_peer_closed());
assert!(matches!(
client.send_probe().unwrap().as_slice(),
[ClientEvent::EchoSent { seq: 1, .. }]
));
assert_eq!(client.send_probe().unwrap().seq, 1);
client.close().unwrap();
server.join();
}
@@ -451,14 +459,11 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -473,9 +478,26 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
));
assert_eq!(events.len(), 2);
let results = [
client.recv_once(),
client.recv_available(RecvBudget { max_packets: 1 }),
];
assert!(
matches!(
results,
[
Err(ClientError::AlreadyClosed),
Err(ClientError::AlreadyClosed)
]
),
"unexpected receive results: {results:?}"
);
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
Err(irtt_client::SendProbeError::NotCommitted(
ClientError::AlreadyClosed
))
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
+47 -14
View File
@@ -8,7 +8,10 @@ fn connect_rejects_invalid_configuration() {
(
"oversized UDP payload",
ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
request: SessionRequest {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..Default::default()
},
..ClientConfig::default()
},
"packet length",
@@ -16,7 +19,10 @@ fn connect_rejects_invalid_configuration() {
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
request: SessionRequest {
duration: Some(Duration::ZERO),
..Default::default()
},
..ClientConfig::default()
},
"duration must be greater than zero; use None for continuous mode",
@@ -24,7 +30,10 @@ fn connect_rejects_invalid_configuration() {
(
"zero interval",
ClientConfig {
interval: Duration::ZERO,
request: SessionRequest {
interval: Duration::ZERO,
..Default::default()
},
..ClientConfig::default()
},
"interval must be greater than zero",
@@ -32,7 +41,10 @@ fn connect_rejects_invalid_configuration() {
(
"duration nanosecond overflow",
ClientConfig {
duration: Some(too_large),
request: SessionRequest {
duration: Some(too_large),
..Default::default()
},
..ClientConfig::default()
},
"duration is too large to encode as nanoseconds",
@@ -40,7 +52,10 @@ fn connect_rejects_invalid_configuration() {
(
"interval nanosecond overflow",
ClientConfig {
interval: too_large,
request: SessionRequest {
interval: too_large,
..Default::default()
},
..ClientConfig::default()
},
"interval is too large to encode as nanoseconds",
@@ -48,7 +63,10 @@ fn connect_rejects_invalid_configuration() {
(
"invalid DSCP codepoint",
ClientConfig {
dscp: 64,
request: SessionRequest {
dscp: 64,
..Default::default()
},
..ClientConfig::default()
},
"dscp",
@@ -58,7 +76,10 @@ fn connect_rejects_invalid_configuration() {
// client mode; a client that needs timestamps must name a clock.
"unspecified clock",
ClientConfig {
clock: Clock::Unspecified,
request: SessionRequest {
clock: Clock::Unspecified,
..Default::default()
},
..ClientConfig::default()
},
"clock must be wall, monotonic, or both",
@@ -66,7 +87,10 @@ fn connect_rejects_invalid_configuration() {
(
"empty server fill",
ClientConfig {
server_fill: Some("".to_owned()),
request: SessionRequest {
server_fill: Some("".to_owned()),
..Default::default()
},
..ClientConfig::default()
},
"server_fill",
@@ -74,7 +98,10 @@ fn connect_rejects_invalid_configuration() {
(
"oversized server fill",
ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
request: SessionRequest {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..Default::default()
},
..ClientConfig::default()
},
"server_fill",
@@ -84,7 +111,7 @@ fn connect_rejects_invalid_configuration() {
for (name, config, expected_reason) in cases {
assert!(
matches!(
Client::connect(config),
Client::connect("127.0.0.1:2112", config),
Err(ClientError::InvalidConfig { .. })
),
"{name} should fail with InvalidConfig ({expected_reason})"
@@ -95,11 +122,14 @@ fn connect_rejects_invalid_configuration() {
#[test]
fn minimum_open_timeout_under_200ms_is_rejected() {
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(199)],
open: OpenPolicy {
timeouts: vec![Duration::from_millis(199)],
..Default::default()
},
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Client::connect("127.0.0.1:2112", config),
Err(ClientError::OpenTimeoutTooSmall { .. })
));
}
@@ -107,11 +137,14 @@ fn minimum_open_timeout_under_200ms_is_rejected() {
#[test]
fn empty_open_timeouts_is_rejected() {
let config = ClientConfig {
open_timeouts: vec![],
open: OpenPolicy {
timeouts: vec![],
..Default::default()
},
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Client::connect("127.0.0.1:2112", config),
Err(ClientError::NoOpenTimeouts)
));
}
+10 -12
View File
@@ -1,10 +1,11 @@
use super::*;
use irtt_client::{Authentication, HmacKey};
#[test]
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
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 mut config = default_test_config();
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -13,8 +14,7 @@ fn hmac_open_ignores_missing_hmac_before_valid_reply() {
socket.send_to(&missing, peer).unwrap();
socket.send_to(&valid, peer).unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(server.rx.try_recv().is_err());
@@ -25,8 +25,8 @@ fn hmac_open_ignores_missing_hmac_before_valid_reply() {
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
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.clone());
let mut config = default_test_config();
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -35,8 +35,7 @@ fn hmac_open_ignores_bad_hmac_before_valid_reply() {
socket.send_to(&bad, peer).unwrap();
socket.send_to(&valid, peer).unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(server.rx.try_recv().is_err());
@@ -46,8 +45,8 @@ fn hmac_open_ignores_bad_hmac_before_valid_reply() {
#[test]
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
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 mut config = default_test_config();
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let mut returned = default_params();
returned.interval_ns += 1;
let server_key = key.clone();
@@ -57,8 +56,7 @@ fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
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();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(matches!(
client.open(),
+1 -1
View File
@@ -1,6 +1,6 @@
use super::*;
use irtt_client::NegotiationPolicy;
use irtt_client::{NegotiatedParams, RunMode, WarningKind, MAX_UDP_PAYLOAD_LENGTH};
use irtt_client::{NegotiationResult, RunMode, WarningKind, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{flags, Params, TimestampFields};
use irtt_proto::{
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock, HMAC_SIZE,
+66 -41
View File
@@ -2,8 +2,8 @@ use super::*;
#[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 mut config = default_test_config();
config.request.run_mode = RunMode::NoTest;
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -15,8 +15,7 @@ fn no_test_rejects_non_close_open_reply() {
.unwrap();
let _ = recv_request(&socket, &tx);
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::UnexpectedNoTestReply)
@@ -32,12 +31,11 @@ fn no_test_rejects_non_close_open_reply() {
#[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 mut config = default_test_config();
config.request.run_mode = RunMode::NoTest;
let params = default_params();
let server = no_test_server(params, TOKEN);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
@@ -47,13 +45,12 @@ fn no_test_rejects_non_zero_token_with_close_reply() {
#[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 config = default_test_config();
config.request.run_mode = RunMode::NoTest;
let mut params = default_params();
params.dscp = 1;
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
@@ -63,52 +60,80 @@ fn no_test_strict_negotiation_rejects_changed_params() {
#[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 = default_params();
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![irtt_client::NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: params.duration_ns,
}]
);
server.join();
for requested_duration in [Some(Duration::from_secs(3)), None] {
let mut config = default_test_config();
config.request.run_mode = RunMode::NoTest;
config.request.duration = requested_duration;
config.open.negotiation = NegotiationPolicy::Loose;
let mut params = default_params();
params.duration_ns = 1_500_000_000;
let server = no_test_server(params.clone(), 0);
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.peer_params, params);
assert_eq!(
negotiated.accepted.duration,
Some(Duration::from_millis(1500))
);
assert_eq!(
negotiated.changes,
vec![irtt_client::NegotiationChange::DurationReduced {
requested: requested_duration,
negotiated: Duration::from_millis(1500),
}]
);
assert_eq!(
client.negotiation(),
None,
"no-test creates no live session"
);
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 mut config = default_test_config();
config.request.run_mode = RunMode::NoTest;
let params = default_params();
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert_no_test_completed(client.open().unwrap());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyCompleted)
Err(irtt_client::SendProbeError::NotCommitted(
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;
fn operations_fail_after_no_test_completed_without_datagrams() {
let mut config = default_test_config();
config.request.run_mode = RunMode::NoTest;
let params = default_params();
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert_no_test_completed(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
let results = [
client.recv_once(),
client.recv_available(RecvBudget { max_packets: 1 }),
];
assert!(
matches!(
results,
[
Err(ClientError::AlreadyCompleted),
Err(ClientError::AlreadyCompleted)
]
),
"unexpected receive results: {results:?}"
);
server.join();
}
+77 -32
View File
@@ -1,5 +1,34 @@
use super::*;
#[test]
fn receive_before_open_returns_not_open_without_datagrams() {
let peer = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let config = default_test_config();
// Bound the old implementation's blocking receive without sending a packet.
let mut client = Client::connect(peer.local_addr().unwrap().to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
let results = [
client.recv_once(),
client.recv_available(RecvBudget { max_packets: 1 }),
client.recv_available(RecvBudget { max_packets: 0 }),
];
assert!(
matches!(
results,
[
Err(ClientError::NotOpen),
Err(ClientError::NotOpen),
Err(ClientError::NotOpen)
]
),
"unexpected receive results: {results:?}"
);
}
// Untrusted returned intervals must be rejected before managed scheduling,
// even when loose negotiation permits legitimate server restrictions.
#[test]
@@ -9,10 +38,13 @@ fn loose_open_rejects_nonpositive_returned_intervals() {
returned.interval_ns = interval_ns;
let server = open_success_server(returned);
let config = ClientConfig {
negotiation_policy: NegotiationPolicy::Loose,
..default_test_config(server.addr)
open: OpenPolicy {
negotiation: NegotiationPolicy::Loose,
..default_test_config().open
},
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(
matches!(client.open(), Err(ClientError::NegotiationRejected { .. })),
"returned interval {interval_ns} must be rejected"
@@ -25,7 +57,7 @@ fn loose_open_rejects_nonpositive_returned_intervals() {
fn open_fails_when_already_open() {
let params = default_params();
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert_open_started(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
server.join();
@@ -42,10 +74,13 @@ fn open_retries_after_first_timeout() {
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)
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
..default_test_config().open
},
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
let outcome = client.open().unwrap();
assert_open_started(outcome);
assert_eq!(server.rx.iter().take(2).count(), 2);
@@ -54,12 +89,12 @@ fn open_retries_after_first_timeout() {
#[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 config = default_test_config();
config.open.negotiation = NegotiationPolicy::Loose;
let mut params = default_params();
params.protocol_version = 2;
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
@@ -76,7 +111,7 @@ fn server_rejection_fails_in_normal_mode() {
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();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
assert_eq!(server.rx.iter().take(1).count(), 1);
server.join();
@@ -93,10 +128,13 @@ fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
let _ = recv_request(&socket, &tx);
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
..default_test_config(server.addr)
open: OpenPolicy {
timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
..default_test_config().open
},
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
let started = Instant::now();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
@@ -113,10 +151,13 @@ fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
fn opening_deadline_overflow_occurs_before_send() {
let server = timeout_server(Duration::from_millis(250));
let config = ClientConfig {
open_timeouts: vec![Duration::MAX],
..default_test_config(server.addr)
open: OpenPolicy {
timeouts: vec![Duration::MAX],
..default_test_config().open
},
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
assert!(server.rx.try_recv().is_err());
@@ -137,7 +178,7 @@ fn post_token_negotiation_failure_sends_cleanup_close() {
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert!(matches!(
client.open(),
@@ -156,14 +197,16 @@ fn post_token_negotiation_failure_sends_cleanup_close() {
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)],
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
..default_test_config().open
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
@@ -195,7 +238,7 @@ fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
socket.send_to(&packet, peer).unwrap();
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
@@ -215,14 +258,16 @@ fn only_ignored_open_datagrams_eventually_time_out() {
thread::sleep(Duration::from_millis(300));
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
..default_test_config().open
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
@@ -242,7 +287,7 @@ fn trusted_zero_token_normal_reply_is_terminal() {
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
assert!(matches!(
client.open(),
@@ -23,14 +23,16 @@ fn short_echo_reply_does_not_emit_echo_reply() {
}
});
let config = ClientConfig {
length: 64,
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
request: SessionRequest {
length: 64,
..default_test_config().request
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -69,14 +71,16 @@ fn overlong_datagram_detection_uses_extra_receive_byte() {
}
});
let config = ClientConfig {
length: 4096,
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
request: SessionRequest {
length: 4096,
..default_test_config().request
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -112,14 +116,16 @@ fn exact_length_echo_reply_still_emits_echo_reply() {
}
});
let config = ClientConfig {
length: 4096,
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
request: SessionRequest {
length: 4096,
..default_test_config().request
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -22,14 +22,11 @@ fn duplicate_reply_emits_duplicate_event() {
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
@@ -76,14 +73,11 @@ fn out_of_order_reply_emits_late_event() {
let reply0 = echo_reply_packet(TOKEN, seqs[0], &params, &ts, None);
socket.send_to(&reply0, peer).unwrap();
});
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
@@ -4,14 +4,11 @@ use super::*;
fn recv_once_returns_empty_on_timeout() {
let params = default_params();
let server = open_success_server(params);
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert_open_started(client.open().unwrap());
let events = client.recv_once().unwrap();
assert!(events.is_empty());
@@ -52,14 +49,11 @@ fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -19,14 +19,11 @@ fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -20,13 +20,15 @@ fn probe_timeout_overflow_transmits_no_echo() {
});
let config = ClientConfig {
probe_timeout: Duration::MAX,
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
assert_open_started(client.open().unwrap());
assert!(matches!(
client.send_probe(),
Err(ClientError::DurationOverflow)
Err(irtt_client::SendProbeError::NotCommitted(
ClientError::DurationOverflow
))
));
assert!(!client.has_pending_probes());
client.close().unwrap();
@@ -46,13 +48,12 @@ fn poll_timeouts_emits_echo_loss() {
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(100),
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -97,13 +98,12 @@ fn late_reply_after_timeout_preserves_measurement_metadata() {
});
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -157,15 +157,17 @@ fn pending_full_does_not_send_packet() {
};
let server = silent_open_server(params);
let config = ClientConfig {
duration: Some(Duration::from_secs(60)),
max_pending_probes: 2,
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
request: SessionRequest {
duration: Some(Duration::from_secs(60)),
..SessionRequest::default()
},
..default_test_config(server.addr)
max_pending_probes: 2,
..default_test_config()
};
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(50)))
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
@@ -181,7 +183,9 @@ fn pending_full_does_not_send_packet() {
assert!(matches!(
client.send_probe(),
Err(ClientError::PendingLimitExceeded { limit: 2 })
Err(irtt_client::SendProbeError::NotCommitted(
ClientError::PendingLimitExceeded { limit: 2 }
))
));
thread::sleep(Duration::from_millis(30));
+20 -30
View File
@@ -25,10 +25,12 @@ impl FakeServer {
}
}
pub(super) fn default_test_config(addr: SocketAddr) -> ClientConfig {
pub(super) fn default_test_config() -> ClientConfig {
ClientConfig {
server_addr: addr.to_string(),
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
..Default::default()
},
..ClientConfig::default()
}
}
@@ -220,46 +222,34 @@ pub(super) fn echo_server(params: Params) -> FakeServer {
})
}
pub(super) fn assert_open_started(outcome: OpenOutcome) -> NegotiatedParams {
pub(super) fn assert_open_started(outcome: OpenOutcome) -> NegotiationResult {
match outcome {
OpenOutcome::Started {
token, negotiated, ..
} => {
OpenOutcome::Started(irtt_client::SessionStarted {
token,
negotiation: negotiated,
..
}) => {
assert_eq!(token, TOKEN);
negotiated
}
OpenOutcome::NoTestCompleted { .. } => panic!("unexpected no-test outcome"),
OpenOutcome::NoTestCompleted(_) => panic!("unexpected no-test outcome"),
}
}
pub(super) fn assert_no_test_completed(outcome: OpenOutcome) -> NegotiatedParams {
pub(super) fn assert_no_test_completed(outcome: OpenOutcome) -> NegotiationResult {
match outcome {
OpenOutcome::NoTestCompleted {
negotiated, event, ..
} => {
assert!(matches!(
event,
ClientEvent::NoTestCompleted {
negotiated: ref event_params,
..
} if *event_params == negotiated
));
negotiated
}
OpenOutcome::Started { .. } => panic!("unexpected started outcome"),
OpenOutcome::NoTestCompleted(completed) => completed.negotiation,
OpenOutcome::Started(_) => panic!("unexpected started outcome"),
}
}
pub(super) fn open_client_with_echo_server(params: &Params) -> (Client, FakeServer) {
let server = echo_server(params.clone());
let config = ClientConfig {
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let config = default_test_config();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert_open_started(client.open().unwrap());
(client, server)
}
+6 -6
View File
@@ -3,19 +3,19 @@ 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();
let mut config = default_test_config();
config.socket.ttl = ttl;
Client::connect(SocketAddr::from(([127, 0, 0, 1], 9)).to_string(), config).unwrap();
}
}
#[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);
let mut config = default_test_config();
config.socket.ttl = Some(ttl);
assert!(matches!(
Client::connect(config),
Client::connect(SocketAddr::from(([127, 0, 0, 1], 9)).to_string(), config),
Err(ClientError::InvalidConfig { .. })
));
}
+48 -24
View File
@@ -9,7 +9,9 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy, SocketConfig};
use irtt_client::{
Client, ClientConfig, ClientEvent, NegotiationPolicy, OpenPolicy, SessionRequest, SocketConfig,
};
use irtt_proto::{decode_request, Clock, DecodedRequestKind, ReceivedStats, StampAt};
use irtt_server::{ServerConfig, ServerCore};
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
@@ -81,21 +83,24 @@ fn peer(dscp_allowed: bool, probes: usize) -> (SocketAddr, thread::JoinHandle<Ve
(addr, task)
}
fn config(addr: SocketAddr) -> ClientConfig {
fn config() -> ClientConfig {
ClientConfig {
server_addr: addr.to_string(),
dscp: 46,
negotiation_policy: NegotiationPolicy::Loose,
duration: None,
interval: Duration::from_millis(10),
clock: Clock::Both,
stamp_at: StampAt::Both,
received_stats: ReceivedStats::Both,
length: 128,
open_timeouts: vec![Duration::from_secs(1)],
socket_config: SocketConfig {
open: OpenPolicy {
negotiation: NegotiationPolicy::Loose,
timeouts: vec![Duration::from_secs(1)],
},
request: SessionRequest {
dscp: 46,
duration: None,
interval: Duration::from_millis(10),
clock: Clock::Both,
stamp_at: StampAt::Both,
received_stats: ReceivedStats::Both,
length: 128,
..Default::default()
},
socket: SocketConfig {
ttl: Some(TEST_TTL),
recv_timeout: Some(Duration::from_secs(1)),
..SocketConfig::default()
},
..ClientConfig::default()
@@ -149,11 +154,14 @@ fn blocking_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
for allowed in [true, false] {
const PROBES: usize = 3;
let (addr, peer) = peer(allowed, PROBES);
let mut client = Client::connect(config(addr)).unwrap();
let mut client = Client::connect(addr.to_string(), config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_secs(1)))
.unwrap();
client.open().unwrap();
assert_eq!(
client.negotiated_params().unwrap().params.dscp,
if allowed { 184 } else { 0 }
client.negotiation().unwrap().accepted.dscp,
if allowed { 46 } else { 0 }
);
for _ in 0..PROBES {
let before = SystemTime::now();
@@ -189,13 +197,13 @@ fn async_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
let (addr, peer) = peer(allowed, PROBES);
runtime.block_on(async {
tokio::time::timeout(Duration::from_secs(5), async {
let mut client = irtt_client::AsyncClient::connect(config(addr))
let mut client = irtt_client::AsyncClient::connect(addr.to_string(), config())
.await
.unwrap();
client.open().await.unwrap();
assert_eq!(
client.negotiated_params().unwrap().params.dscp,
if allowed { 184 } else { 0 }
client.negotiation().unwrap().accepted.dscp,
if allowed { 46 } else { 0 }
);
for _ in 0..PROBES {
let before = SystemTime::now();
@@ -245,15 +253,31 @@ fn a_closed_udp_peer_surfaces_a_socket_error() {
close_rx.recv_timeout(Duration::from_secs(3)).unwrap();
drop(socket);
});
let mut client = Client::connect(config(addr)).unwrap();
let mut client = Client::connect(addr.to_string(), config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_secs(1)))
.unwrap();
client.open().unwrap();
close_tx.send(()).unwrap();
task.join().unwrap();
// Refusal can arrive during send's TX drain or the following receive.
let result = client.send_probe().and_then(|_| client.recv_once());
// Either way this first Echo was accepted and must stay pending.
let (receipt, error) = match client.send_probe() {
Ok(receipt) => (receipt, client.recv_once().unwrap_err()),
Err(irtt_client::SendProbeError::AfterCommit { receipt, source }) => (receipt, *source),
Err(error) => panic!("first Echo should be accepted: {error:?}"),
};
assert_eq!(receipt.seq, 0);
assert_eq!(receipt.remote, addr);
assert!(client.has_pending_probes());
assert!(
matches!(result, Err(irtt_client::ClientError::Socket(ref error))
matches!(error, irtt_client::ClientError::Socket(ref error)
if error.kind() == std::io::ErrorKind::ConnectionRefused),
"{result:?}"
"{error:?}"
);
assert!(matches!(
client.poll_timeouts_at(receipt.sent_at.mono + client.probe_timeout())
.unwrap().as_slice(),
[ClientEvent::EchoLoss { seq: 0, sent_at, .. }] if *sent_at == receipt.sent_at
));
}
+44 -5
View File
@@ -20,6 +20,32 @@ fn config() -> ManagedClientConfig {
#[test]
fn stop_resolves_every_accepted_update_and_closes_admission() {
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
let mut subscription: ManagedStatusSubscription = handle.subscribe_status();
assert_eq!(
subscription.borrow().lifecycle,
ManagedLifecycle::NotStarted
);
assert!(!subscription.borrow().stop_requested);
assert!(subscription.borrow().final_outcome.is_none());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut task = pin!(task);
{
let _runtime = runtime.enter();
assert!(task
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
.is_pending());
}
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
subscription = handle.subscribe_status();
assert_eq!(subscription.borrow().lifecycle, ManagedLifecycle::Running);
assert!(pin!(subscription.changed())
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
.is_pending());
let receipts: Vec<_> = (0..3)
.map(|index| {
handle
@@ -35,15 +61,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
Err(ManagedCommandError::QueueFull)
));
let stop = handle.stop();
let mut stop = pin!(handle.stop());
assert!(stop
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
.is_pending());
assert!(!handle.status().stop_requested);
assert!(matches!(
handle.update_targets(vec![]),
Err(ManagedCommandError::Stopping)
));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
tokio::time::timeout(Duration::from_secs(2), async {
let outcome = task.await;
@@ -61,6 +88,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
assert!(status.stop_requested);
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
subscription.changed().await.unwrap();
assert!(std::sync::Arc::ptr_eq(
&subscription.borrow_and_update(),
&status
));
assert!(subscription.changed().await.is_err());
assert_eq!(
subscription.borrow().final_outcome.as_deref(),
Some(&outcome)
);
assert!(matches!(
handle.update_targets(vec![]),
Err(ManagedCommandError::DriverClosed)
@@ -103,12 +140,14 @@ fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() {
}
let status = handle.status();
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
assert!(!status.stop_requested);
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
assert!(matches!(
handle.update_targets(vec![]),
Err(ManagedCommandError::DriverClosed)
));
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
assert!(!handle.status().stop_requested);
}
#[test]
+1 -1
View File
@@ -10,4 +10,4 @@ publish = false
irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
irtt-server.workspace = true
irtt-proto.workspace = true
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
@@ -1,6 +1,8 @@
use std::time::Duration;
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome, SocketConfig};
use irtt_client::{
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use irtt_server::{Server, ServerConfig};
use tokio::{sync::oneshot, time::timeout};
@@ -27,54 +29,53 @@ async fn exercise_client_server() {
});
let config = ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..SocketConfig::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Default::default()
},
probe_timeout: Duration::from_millis(200),
..ClientConfig::default()
};
let mut client = AsyncClient::connect(config).await.unwrap();
let mut client = AsyncClient::connect(server_addr.to_string(), config)
.await
.unwrap();
let opened = client.open().await.unwrap();
match &opened {
OpenOutcome::Started {
OpenOutcome::Started(irtt_client::SessionStarted {
remote,
token,
negotiated,
event,
} => {
negotiation: negotiated,
..
}) => {
assert_eq!(*remote, server_addr);
assert_ne!(*token, 0);
assert_eq!(negotiated.params.received_stats, ReceivedStats::Both);
assert_eq!(negotiated.params.stamp_at, StampAt::Both);
assert_eq!(negotiated.params.clock, Clock::Both);
assert_eq!(negotiated.accepted.received_stats, ReceivedStats::Both);
assert_eq!(negotiated.accepted.stamp_at, StampAt::Both);
assert_eq!(negotiated.accepted.clock, Clock::Both);
// A default client requests no fill, and this open succeeded under
// the default strict negotiation policy — which it could not have
// if the server had answered an absent request with its own default
// descriptor. The server still fills the payload with it.
assert_eq!(negotiated.params.server_fill, None);
assert!(matches!(event, ClientEvent::SessionStarted { .. }));
assert_eq!(negotiated.accepted.server_fill, None);
}
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
}
assert!(matches!(
client.send_probe().await.unwrap().as_slice(),
[ClientEvent::EchoSent {
seq: 0,
remote,
..
}] if *remote == server_addr
));
let receipt = client.send_probe().await.unwrap();
assert_eq!(receipt.seq, 0);
assert_eq!(receipt.remote, server_addr);
assert!(client.has_pending_probes());
let events = client.recv().await.unwrap();
let [ClientEvent::EchoReply {
seq: 0,
@@ -92,6 +93,7 @@ async fn exercise_client_server() {
panic!("expected a measured reply, got {events:?}");
};
assert_eq!(*remote, server_addr);
assert_eq!(*sent_at, receipt.sent_at);
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
// Verify the measurement endpoint through public reply metadata and real
// OS timestamps. This also covers the userspace fallback on other targets.
@@ -1,3 +1,4 @@
use irtt_client::{Authentication, HmacKey, OpenPolicy, SessionRequest};
use std::{
future::Future,
net::UdpSocket,
@@ -16,7 +17,7 @@ use tokio::runtime::{Builder, Runtime};
mod support;
use irtt_client::{AsyncClient, ClientConfig, ClientError, ClientEvent, OpenOutcome};
use irtt_client::{Client, RunMode, SocketConfig};
use irtt_client::{Client, RunMode, SendProbeError};
use irtt_server::ServerConfig;
use std::{
io,
@@ -48,24 +49,27 @@ fn runtime() -> Runtime {
.unwrap()
}
fn config(addr: SocketAddr, key: Option<Vec<u8>>, dscp: u8) -> ClientConfig {
fn config(key: Option<Vec<u8>>, dscp: u8) -> ClientConfig {
ClientConfig {
server_addr: addr.to_string(),
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
dscp,
hmac_key: key,
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..SocketConfig::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
dscp,
..Default::default()
},
auth: key.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key))
}),
..ClientConfig::default()
}
}
async fn opened_client(addr: SocketAddr, dscp: u8) -> AsyncClient {
let mut client = AsyncClient::connect(config(addr, None, dscp))
let mut client = AsyncClient::connect(addr.to_string(), config(None, dscp))
.await
.unwrap();
client.open().await.unwrap();
@@ -248,7 +252,10 @@ fn poll_recv_once(client: &mut AsyncClient) -> Poll<Result<Vec<ClientEvent>, Cli
#[test]
fn connect_requires_current_runtime_when_polled() {
let mut future = Box::pin(AsyncClient::connect(ClientConfig::default()));
let mut future = Box::pin(AsyncClient::connect(
"127.0.0.1:2112",
ClientConfig::default(),
));
assert!(matches!(
poll_once(Pin::as_mut(&mut future)),
Poll::Ready(Err(ClientError::NoTokioRuntime))
@@ -259,12 +266,19 @@ fn connect_requires_current_runtime_when_polled() {
fn recv_before_open_fails_on_first_poll_without_socket_readiness() {
runtime().block_on(async {
let remote = SocketAddr::from(([127, 0, 0, 1], 2112));
let mut client = AsyncClient::connect(config(remote, None, 0)).await.unwrap();
let mut client = AsyncClient::connect(remote.to_string(), config(None, 0))
.await
.unwrap();
assert!(matches!(
poll_recv_once(&mut client),
Poll::Ready(Err(ClientError::NotOpen))
));
let mut send = Box::pin(client.send_probe());
assert!(matches!(
poll_once(send.as_mut()),
Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::NotOpen)))
));
});
}
@@ -309,14 +323,16 @@ fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
}
panic!("second open request did not arrive after the first absolute deadline");
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
let mut client_config = config(None, 0);
client_config.open.timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
let mut client = AsyncClient::connect(server.addr.to_string(), client_config)
.await
.unwrap();
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::Started { .. }
OpenOutcome::Started(_)
));
});
assert_eq!(server.finish().len(), 2);
@@ -336,11 +352,13 @@ fn authenticated_rejection_is_terminal_without_retry() {
0,
);
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
let mut client_config = config(None, 0);
client_config.open.timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
let mut client = AsyncClient::connect(server.addr.to_string(), client_config)
.await
.unwrap();
assert!(matches!(
client.open().await,
Err(ClientError::ServerRejected)
@@ -363,19 +381,35 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
poll_recv_once(&mut client),
Poll::Ready(Err(ClientError::AlreadyClosed))
));
let mut send = Box::pin(client.send_probe());
assert!(matches!(
poll_once(send.as_mut()),
Poll::Ready(Err(SendProbeError::NotCommitted(
ClientError::AlreadyClosed
)))
));
});
assert_eq!(close_server.finish().len(), 2);
let no_test_server = start_no_test_server();
runtime().block_on(async {
let mut client_config = config(no_test_server.addr, None, 0);
client_config.run_mode = RunMode::NoTest;
let mut client = AsyncClient::connect(client_config).await.unwrap();
let mut client_config = config(None, 0);
client_config.request.run_mode = RunMode::NoTest;
let mut client = AsyncClient::connect(no_test_server.addr.to_string(), client_config)
.await
.unwrap();
client.open().await.unwrap();
assert!(matches!(
poll_recv_once(&mut client),
Poll::Ready(Err(ClientError::AlreadyCompleted))
));
let mut send = Box::pin(client.send_probe());
assert!(matches!(
poll_once(send.as_mut()),
Poll::Ready(Err(SendProbeError::NotCommitted(
ClientError::AlreadyCompleted
)))
));
});
assert_eq!(no_test_server.finish().len(), 1);
}
@@ -385,11 +419,11 @@ fn authenticated_async_lifecycle_negotiates_dscp() {
let key = b"async-lifecycle-key".to_vec();
let server = InTreeServer::start(ServerConfig::default().with_hmac_key(key.clone()));
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, Some(key), 46))
let mut client = AsyncClient::connect(server.addr.to_string(), config(Some(key), 46))
.await
.unwrap();
let opened = client.open().await.unwrap();
assert_eq!(open_negotiated(&opened).params.dscp, 184);
assert_eq!(open_negotiated(&opened).accepted.dscp, 46);
client.send_probe().await.unwrap();
assert!(matches!(client.recv().await.unwrap().as_slice(), [ClientEvent::EchoReply { seq: 0, .. }]));
assert!(matches!(client.close().await.unwrap().as_slice(), [ClientEvent::SessionClosed { token, .. }] if *token == open_token(&opened)));
@@ -400,7 +434,10 @@ fn authenticated_async_lifecycle_negotiates_dscp() {
fn blocking_and_async_peer_close_are_semantically_equivalent() {
let blocking_peer_close_server = start_peer_close_server();
let mut blocking_peer_close =
Client::connect(config(blocking_peer_close_server.addr, None, 0)).unwrap();
Client::connect(blocking_peer_close_server.addr.to_string(), config(None, 0)).unwrap();
blocking_peer_close
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
blocking_peer_close.open().unwrap();
blocking_peer_close.send_probe().unwrap();
let blocking_peer_close_events = blocking_peer_close.recv_once().unwrap();
@@ -409,9 +446,10 @@ fn blocking_and_async_peer_close_are_semantically_equivalent() {
let async_peer_close_server = start_peer_close_server();
let async_peer_close_events = runtime().block_on(async {
let mut client = AsyncClient::connect(config(async_peer_close_server.addr, None, 0))
.await
.unwrap();
let mut client =
AsyncClient::connect(async_peer_close_server.addr.to_string(), config(None, 0))
.await
.unwrap();
client.open().await.unwrap();
client.send_probe().await.unwrap();
let events = client.recv().await.unwrap();
@@ -431,17 +469,23 @@ fn blocking_and_async_peer_close_are_semantically_equivalent() {
}
}
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiatedParams {
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiationResult {
match outcome {
OpenOutcome::Started { negotiated, .. }
| OpenOutcome::NoTestCompleted { negotiated, .. } => negotiated,
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
})
| OpenOutcome::NoTestCompleted(irtt_client::NoTestCompleted {
negotiation: negotiated,
..
}) => negotiated,
}
}
fn open_token(outcome: &OpenOutcome) -> u64 {
match outcome {
OpenOutcome::Started { token, .. } => *token,
OpenOutcome::NoTestCompleted { .. } => panic!("no-test open has no session token"),
OpenOutcome::Started(irtt_client::SessionStarted { token, .. }) => *token,
OpenOutcome::NoTestCompleted(_) => panic!("no-test open has no session token"),
}
}
@@ -522,13 +566,16 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
// One open attempt is ignored, so the first open times out and the second
// must be able to open the same connected client.
let blocking_server = start_silent_then_open_server(1);
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap();
let mut blocking = Client::connect(blocking_server.addr.to_string(), config(None, 0)).unwrap();
blocking
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
assert!(!blocking.has_pending_probes());
let blocking_first = blocking.open().unwrap_err();
assert!(!blocking.has_pending_probes());
let blocking_second = blocking.open().unwrap();
assert!(!blocking.has_pending_probes());
let blocking_sent = blocking.send_probe().unwrap();
let blocking_sent = [ClientEvent::from(blocking.send_probe().unwrap())];
let blocking_reply = blocking.recv_once().unwrap();
let blocking_close = blocking.close().unwrap();
assert_eq!(blocking_server.finish().len(), 4);
@@ -536,7 +583,7 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
let async_server = start_silent_then_open_server(1);
let (async_first, async_second, async_sent, async_reply, async_close) =
runtime().block_on(async {
let mut client = AsyncClient::connect(config(async_server.addr, None, 0))
let mut client = AsyncClient::connect(async_server.addr.to_string(), config(None, 0))
.await
.unwrap();
assert!(!client.has_pending_probes());
@@ -544,7 +591,7 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
assert!(!client.has_pending_probes());
let second = client.open().await.unwrap();
assert!(!client.has_pending_probes());
let sent = client.send_probe().await.unwrap();
let sent = [ClientEvent::from(client.send_probe().await.unwrap())];
let reply = client.recv().await.unwrap();
let closed = client.close().await.unwrap();
(first, second, sent, reply, closed)
@@ -555,8 +602,8 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
// state that refuses the retry.
assert_eq!(error_name(&blocking_first), "open timeout");
assert_eq!(error_name(&blocking_first), error_name(&async_first));
assert!(matches!(blocking_second, OpenOutcome::Started { .. }));
assert!(matches!(async_second, OpenOutcome::Started { .. }));
assert!(matches!(blocking_second, OpenOutcome::Started(_)));
assert!(matches!(async_second, OpenOutcome::Started(_)));
assert_eq!(
open_negotiated(&blocking_second),
open_negotiated(&async_second)
@@ -577,21 +624,18 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
#[test]
fn blocking_and_async_complete_every_caller_paced_probe() {
const PROBES: u32 = 3;
fn caller_config(addr: SocketAddr) -> ClientConfig {
ClientConfig {
duration: Some(Duration::from_millis(1)),
interval: Duration::from_secs(1),
..config(addr, None, 0)
}
}
let mut caller_config = config(None, 0);
caller_config.request.duration = Some(Duration::from_millis(1));
caller_config.request.interval = Duration::from_secs(1);
let blocking_server = InTreeServer::start(ServerConfig::default());
let mut blocking = Client::connect(caller_config(blocking_server.addr)).unwrap();
let mut blocking =
Client::connect(blocking_server.addr.to_string(), caller_config.clone()).unwrap();
blocking
.set_recv_timeout(Some(Duration::from_millis(200)))
.unwrap();
let outcome = blocking.open().unwrap();
assert_eq!(
blocking.negotiated_params(),
Some(open_negotiated(&outcome))
);
assert_eq!(blocking.negotiation(), Some(open_negotiated(&outcome)));
assert!(!blocking.has_pending_probes());
// Oversleeping cannot invalidate this: low-level sends remain caller-owned
// after the negotiated duration. The compliant server has no duration cap.
@@ -599,7 +643,7 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
let mut blocking_sent = Vec::new();
let mut blocking_replies = Vec::new();
for _ in 0..PROBES {
blocking_sent.extend(blocking.send_probe().unwrap());
blocking_sent.push(ClientEvent::from(blocking.send_probe().unwrap()));
assert!(blocking.has_pending_probes());
assert!(blocking.next_probe_timeout_deadline().is_some());
blocking_replies.extend(blocking.recv_once().unwrap());
@@ -610,16 +654,16 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
let async_server = InTreeServer::start(ServerConfig::default());
let (async_sent, async_replies) = runtime().block_on(async {
let mut client = AsyncClient::connect(caller_config(async_server.addr))
let mut client = AsyncClient::connect(async_server.addr.to_string(), caller_config)
.await
.unwrap();
let outcome = client.open().await.unwrap();
assert_eq!(client.negotiated_params(), Some(open_negotiated(&outcome)));
assert_eq!(client.negotiation(), Some(open_negotiated(&outcome)));
tokio::time::sleep(Duration::from_millis(2)).await;
let mut sent = Vec::new();
let mut replies = Vec::new();
for _ in 0..PROBES {
sent.extend(client.send_probe().await.unwrap());
sent.push(ClientEvent::from(client.send_probe().await.unwrap()));
assert!(client.has_pending_probes());
assert!(client.next_probe_timeout_deadline().is_some());
replies.extend(client.recv().await.unwrap());
@@ -667,11 +711,13 @@ fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
}
}
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
let mut client_config = config(None, 0);
client_config.open.timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
let mut client = AsyncClient::connect(server.addr.to_string(), client_config)
.await
.unwrap();
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
});
assert_eq!(server.finish().len(), 2);
@@ -1,3 +1,4 @@
use irtt_client::{OpenPolicy, SessionRequest};
use std::{
net::{SocketAddr, UdpSocket},
sync::{Arc, Condvar, Mutex},
@@ -146,12 +147,18 @@ fn test_runtime() -> tokio::runtime::Runtime {
fn config(completion: ManagedCompletionPolicy) -> ManagedClientConfig {
ManagedClientConfig {
client: ClientConfig {
duration: Some(Duration::from_millis(120)),
interval: Duration::from_millis(20),
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_millis(120)),
interval: Duration::from_millis(20),
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
..Default::default()
},
probe_timeout: Duration::from_millis(40),
open_timeouts: vec![Duration::from_millis(200)],
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
..ClientConfig::default()
},
pacing: ManagedPacing::Burst,
@@ -1,6 +1,9 @@
use std::time::Duration;
mod support;
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
use irtt_client::{
Client, ClientConfig, ClientError, NegotiationPolicy, OpenPolicy, SessionRequest,
};
use irtt_proto::{Params, TimestampFields};
use support::{
@@ -52,11 +55,14 @@ fn server_fill_none_short_and_max_map_into_open_params() {
fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
let config = ClientConfig {
server_fill: Some(fill.to_owned()),
request: SessionRequest {
server_fill: Some(fill.to_owned()),
..Default::default()
},
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Client::connect("127.0.0.1:2112", config),
Err(ClientError::InvalidConfig { .. })
));
}
@@ -69,13 +75,22 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
let mut params = default_params();
params.server_fill = server_fill("rand");
let server = InTreeServer::start(irtt_server::ServerConfig::default());
let mut client = Client::connect(config_for_params(server.addr, &params)).unwrap();
let mut client = Client::connect(server.addr.to_string(), config_for_params(&params)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
let negotiated = match outcome {
irtt_client::OpenOutcome::Started { negotiated, .. } => negotiated,
irtt_client::OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => negotiated,
other => panic!("expected started outcome, got {other:?}"),
};
assert_eq!(negotiated.params.server_fill, params.server_fill);
assert_eq!(
negotiated.accepted.server_fill,
params.server_fill.map(|fill| fill.value)
);
client.send_probe().unwrap();
assert!(matches!(
@@ -99,7 +114,11 @@ fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
returned.server_fill = returned_fill.and_then(server_fill);
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
let mut client =
Client::connect(server.addr.to_string(), config_for_params(&requested)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
@@ -117,10 +136,13 @@ fn server_fill_loose_allows_server_to_remove_fill() {
returned.server_fill = None;
let requested_for_config = requested.clone();
let run = run_one_probe_with_config(returned, TimestampFields::default(), None, |addr| {
let run = run_one_probe_with_config(returned, TimestampFields::default(), None, || {
ClientConfig {
negotiation_policy: NegotiationPolicy::Loose,
..config_for_params(addr, &requested_for_config)
open: OpenPolicy {
negotiation: NegotiationPolicy::Loose,
..config_for_params(&requested_for_config).open
},
..config_for_params(&requested_for_config)
}
});
@@ -128,7 +150,7 @@ fn server_fill_loose_allows_server_to_remove_fill() {
open_observation_params(&run).server_fill,
requested.server_fill
);
assert_eq!(run.negotiated.params.server_fill, None);
assert_eq!(run.negotiated.accepted.server_fill, None);
}
// ─── Echo behavior ───
@@ -153,11 +175,11 @@ fn server_fill_does_not_affect_client_side_packet_layout() {
let (echo_len_without, _, _, _) = echo_observation(&run_without);
assert_eq!(echo_len_with, echo_len_without);
assert_eq!(
test_echo_packet_len(false, &run_with.negotiated.params),
test_echo_packet_len(false, &run_with.negotiated.peer_params),
echo_len_with
);
assert_eq!(
test_echo_packet_len(false, &run_without.negotiated.params),
test_echo_packet_len(false, &run_without.negotiated.peer_params),
echo_len_without
);
}
@@ -10,7 +10,8 @@
use std::time::Duration;
use irtt_client::{
AsyncClient, ClientConfig, NegotiationPolicy, NegotiationRestriction, OpenOutcome, SocketConfig,
AsyncClient, ClientConfig, NegotiationChange, NegotiationPolicy, OpenOutcome, OpenPolicy,
SessionRequest,
};
use irtt_proto::{
decode_echo_reply, decode_open_reply, echo_header_len, encode_request, Clock, Params,
@@ -132,24 +133,26 @@ async fn exercise_fallback_negotiation() {
// `ClientConfig` accepts any non-empty descriptor within the wire bound, so
// an unknown one genuinely reaches the server.
let config = |policy| ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
server_fill: Some("bogus".to_owned()),
negotiation_policy: policy,
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..SocketConfig::default()
open: OpenPolicy {
negotiation: policy,
timeouts: vec![Duration::from_millis(200)],
},
request: SessionRequest {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
server_fill: Some("bogus".to_owned()),
..Default::default()
},
probe_timeout: Duration::from_millis(200),
..ClientConfig::default()
};
let mut strict = AsyncClient::connect(config(NegotiationPolicy::Strict))
.await
.unwrap();
let mut strict =
AsyncClient::connect(server_addr.to_string(), config(NegotiationPolicy::Strict))
.await
.unwrap();
let rejected = strict
.open()
.await
@@ -159,25 +162,27 @@ async fn exercise_fallback_negotiation() {
"the restriction names the fill: {rejected}"
);
let mut loose = AsyncClient::connect(config(NegotiationPolicy::Loose))
let mut loose = AsyncClient::connect(server_addr.to_string(), config(NegotiationPolicy::Loose))
.await
.unwrap();
match loose.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => {
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => {
assert_eq!(
negotiated
.params
.server_fill
.map(|fill| fill.value)
.as_deref(),
negotiated.accepted.server_fill.as_deref(),
Some("pattern:69727474"),
"the server reports the default descriptor it fell back to"
);
assert!(negotiated
.restrictions
.contains(&NegotiationRestriction::ServerFillChanged));
.changes
.contains(&NegotiationChange::ServerFillChanged {
requested: Some("bogus".to_owned()),
negotiated: Some("pattern:69727474".to_owned())
}));
}
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
}
loose.close().await.unwrap();
+67 -53
View File
@@ -1,10 +1,9 @@
use irtt_client::{Authentication, HmacKey};
mod support;
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OpenOutcome, SocketConfig, WarningKind,
};
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, OpenOutcome, WarningKind};
use irtt_proto::TimestampFields;
use support::{
@@ -26,13 +25,13 @@ fn hmac_open_success_negotiates_without_warnings() {
params.clone(),
TimestampFields::default(),
Some(key),
|addr| ClientConfig {
hmac_key: Some(config_key),
..config_for_params(addr, &config_params)
|| ClientConfig {
auth: Authentication::Hmac(HmacKey::new(config_key)),
..config_for_params(&config_params)
},
);
assert_eq!(run.negotiated.params, params);
assert_eq!(run.negotiated.peer_params, params);
match &run.observations[0] {
ServerObservation::Open { params: got, hmac } => {
assert_eq!(got, &params);
@@ -50,10 +49,10 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
let config_params = params.clone();
let config_key = key.clone();
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), |addr| {
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), || {
ClientConfig {
hmac_key: Some(config_key),
..config_for_params(addr, &config_params)
auth: Authentication::Hmac(HmacKey::new(config_key)),
..config_for_params(&config_params)
}
});
@@ -93,10 +92,13 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
let key = b"compat-secret".to_vec();
let params = default_params();
let server = start_hmac_close_server(params.clone(), key.clone());
let mut config = config_for_params(server.addr, &params);
config.hmac_key = Some(key);
let mut config = config_for_params(&params);
config.auth = Authentication::Hmac(HmacKey::new(key));
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
@@ -107,7 +109,9 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
));
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
Err(irtt_client::SendProbeError::NotCommitted(
ClientError::AlreadyClosed
))
));
let observations = server.observations(2);
@@ -133,11 +137,16 @@ fn hmac_required_server_rejects_missing_or_wrong_client_key() {
] {
let server_key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut config = config_for_params(&default_params());
config.open.timeouts = vec![Duration::from_millis(200)];
config.auth = hmac_key.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key))
});
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let observations = server.observations(1);
@@ -157,19 +166,18 @@ fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
let key = b"compat-secret".to_vec();
let params = default_params();
let server = start_bad_hmac_echo_reply_server(params.clone(), key.clone());
let mut config = config_for_params(server.addr, &params);
config.hmac_key = Some(key);
config.socket_config = SocketConfig {
recv_timeout: Some(Duration::from_millis(500)),
..Default::default()
};
let mut config = config_for_params(&params);
config.auth = Authentication::Hmac(HmacKey::new(key));
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent.seq, 0);
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
@@ -204,10 +212,13 @@ fn hmac_open_reply_with_bad_hmac_is_ignored_until_timeout() {
let wrong_key = b"wrong-secret".to_vec();
let params = default_params();
let server = support::start_bad_hmac_open_reply_server(params.clone(), key.clone(), wrong_key);
let mut config = config_for_params(server.addr, &params);
config.hmac_key = Some(key);
let mut config = config_for_params(&params);
config.auth = Authentication::Hmac(HmacKey::new(key));
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
server.join();
}
@@ -220,10 +231,13 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
irtt_proto::Clock::Both,
);
let server = support::start_open_server(params.clone(), None);
let mut config = config_for_params(server.addr, &params);
config.hmac_key = None;
let mut config = config_for_params(&params);
config.auth = Authentication::Unauthenticated;
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
@@ -237,21 +251,13 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
fn assert_started(outcome: OpenOutcome, expected: &irtt_proto::Params) {
match outcome {
OpenOutcome::Started {
OpenOutcome::Started(irtt_client::SessionStarted {
token,
negotiated,
event:
ClientEvent::SessionStarted {
token: event_token,
negotiated: event_negotiated,
..
},
negotiation: negotiated,
..
} => {
}) => {
assert_eq!(token, TOKEN);
assert_eq!(event_token, TOKEN);
assert_eq!(negotiated.params, *expected);
assert_eq!(event_negotiated.params, *expected);
assert_eq!(negotiated.peer_params, *expected);
}
other => panic!("expected started open outcome, got {other:?}"),
}
@@ -277,15 +283,18 @@ fn backend_hmac_correct_key_succeeds() {
let key = b"compat-secret".to_vec();
let params = default_params();
let peer = BackendPeer::start_open_echo(Some(key.clone()));
let mut config = config_for_params(peer.addr(), &params);
config.hmac_key = Some(key);
let mut config = config_for_params(&params);
config.auth = Authentication::Hmac(HmacKey::new(key));
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(peer.addr().to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, OpenOutcome::Started { .. }));
assert!(matches!(outcome, OpenOutcome::Started(_)));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent.seq, 0);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
@@ -299,11 +308,16 @@ fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut config = config_for_params(&default_params());
config.open.timeouts = vec![Duration::from_millis(200)];
config.auth = hmac_key.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key))
});
let mut client = Client::connect(config).unwrap();
let mut client = Client::connect(peer.addr().to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
}
@@ -1,6 +1,6 @@
mod support;
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy};
use irtt_client::{Client, ClientEvent, NegotiationPolicy};
use irtt_proto::{echo_header_len, Params, TimestampFields};
use support::{config_for_params, default_params, run_one_probe, BackendPeer, ServerObservation};
@@ -84,15 +84,16 @@ fn echo_reply_bytes_consistent_with_echo_packet_len_for_various_lengths() {
fn backend_large_packet_smoke() {
let params = params_with_length(1472);
let peer = BackendPeer::start_open_echo(None);
let mut client = Client::connect(ClientConfig {
negotiation_policy: NegotiationPolicy::Loose,
length: 1472,
..config_for_params(peer.addr(), &params)
})
.unwrap();
let mut config = config_for_params(&params);
config.open.negotiation = NegotiationPolicy::Loose;
config.request.length = 1472;
let mut client = Client::connect(peer.addr().to_string(), config).unwrap();
client
.set_recv_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
@@ -1,8 +1,8 @@
mod support;
use std::{collections::HashMap, time::Duration};
use std::{collections::HashMap, sync::Arc, time::Duration};
use irtt_client::{managed::*, ClientConfig, ClientEvent};
use irtt_client::{managed::*, ClientConfig, ClientEvent, OpenPolicy, SessionRequest};
use irtt_server::ServerConfig;
use support::InTreeServer;
use tokio::time::timeout;
@@ -18,10 +18,16 @@ async fn both_pacing_modes_serve_every_target_on_its_negotiated_grid() {
for _ in 0..3 {
let config = ManagedClientConfig {
client: ClientConfig {
duration: Some(Duration::from_millis(600)),
interval: Duration::from_millis(100),
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_millis(600)),
interval: Duration::from_millis(100),
..Default::default()
},
probe_timeout: Duration::from_millis(100),
open_timeouts: vec![Duration::from_millis(200)],
..ClientConfig::default()
},
pacing,
@@ -111,10 +117,16 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
let server = InTreeServer::start(ServerConfig::default());
let config = ManagedClientConfig {
client: ClientConfig {
duration: None,
interval: Duration::from_millis(20),
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
duration: None,
interval: Duration::from_millis(20),
..Default::default()
},
probe_timeout: Duration::from_millis(100),
open_timeouts: vec![Duration::from_millis(200)],
..ClientConfig::default()
},
completion: ManagedCompletionPolicy::ExplicitStop,
@@ -150,7 +162,7 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
match events.recv().await.unwrap() {
ManagedEvent::Client {
target,
event: ClientEvent::SessionStarted { .. },
event: ClientEvent::SessionStarted(_),
} if target == first => panic!("identical update reopened the session"),
ManagedEvent::Client {
target,
@@ -174,6 +186,9 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
);
assert!(status.targets.iter().all(|target| target.target != first
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
if let Some(target) = status.targets.iter().find(|target| target.target == first) {
assert!(Arc::ptr_eq(target.outcome.as_ref().unwrap(), &outcome));
}
break;
}
}
@@ -212,11 +227,21 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
);
assert!(status.targets.iter().all(|target| target.target != second
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
let target = status
.targets
.iter()
.find(|target| target.target == second)
.unwrap();
assert!(Arc::ptr_eq(target.outcome.as_ref().unwrap(), &outcome));
}
ManagedEvent::Completed { outcome } => {
let status = handle.status();
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
assert!(Arc::ptr_eq(
status.final_outcome.as_ref().unwrap(),
&outcome
));
break;
}
_ => {}
@@ -1,3 +1,4 @@
use irtt_client::{Authentication, HmacKey, OpenPolicy, SessionRequest};
use std::{
future::Future,
net::{SocketAddr, UdpSocket},
@@ -14,8 +15,8 @@ use irtt_proto::{
};
use tokio::runtime::{Builder, Runtime};
use irtt_client::RunMode;
use irtt_client::{managed::*, ClientConfig};
use irtt_client::{ClientAuthConfig, RunMode};
use tokio::sync::broadcast;
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
@@ -175,12 +176,18 @@ fn runtime() -> Runtime {
fn config(pacing: ManagedPacing) -> ManagedClientConfig {
ManagedClientConfig {
client: ClientConfig {
duration: Some(Duration::from_millis(130)),
interval: Duration::from_millis(60),
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_millis(130)),
interval: Duration::from_millis(60),
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
..Default::default()
},
probe_timeout: Duration::from_millis(35),
open_timeouts: vec![Duration::from_millis(200)],
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
..ClientConfig::default()
},
pacing,
@@ -231,23 +238,50 @@ fn quiescent_rejects_empty_initial_targets() {
#[test]
fn explicit_empty_waits_for_stop() {
let server = start_server(ServerBehavior::Echo, None);
let mut config = config(ManagedPacing::Staggered);
config.completion = ManagedCompletionPolicy::ExplicitStop;
config.client.request.duration = None;
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
let outcome = runtime().block_on(async move {
let mut task = Box::pin(task);
assert!(
tokio::time::timeout(Duration::from_millis(20), task.as_mut())
.await
.is_err()
);
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
let receipt = handle.stop();
let outcome = task.await;
receipt.await;
outcome
let mut events = handle.subscribe().unwrap();
let outcome = runtime().block_on(async {
tokio::time::timeout(Duration::from_secs(2), async {
let mut task = Box::pin(task);
assert!(poll_once(task.as_mut()).is_pending());
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
let update = handle
.update_targets(vec![target("one", server.addr)])
.unwrap();
loop {
tokio::select! {
_ = task.as_mut() => panic!("explicit-stop task completed before stop"),
event = events.recv() => {
if matches!(event.unwrap(), ManagedEvent::Client {
event: irtt_client::ClientEvent::EchoReply { .. }, ..
}) {
break;
}
}
}
}
update.await.unwrap();
let mut receipt = Box::pin(handle.stop());
assert!(poll_once(receipt.as_mut()).is_pending());
assert!(poll_once(task.as_mut()).is_pending());
let status = handle.status();
assert_eq!(status.lifecycle, ManagedLifecycle::Stopping);
assert!(status.stop_requested);
assert!(status.final_outcome.is_none());
// The driver is paused during graceful cleanup: receipt readiness must
// come from durable stop observation, not eventual task completion.
assert!(poll_once(receipt.as_mut()).is_ready());
task.await
})
.await
.expect("stop observation and cleanup must complete")
});
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
assert!(has_close(&server.finish()));
}
#[test]
@@ -304,6 +338,8 @@ fn pre_poll_stop() {
)
.unwrap();
let mut receipt = Box::pin(handle.stop());
assert!(poll_once(receipt.as_mut()).is_pending());
assert!(!handle.status().stop_requested);
let mut task = Box::pin(task);
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
panic!("pre-stopped task did not complete")
@@ -339,7 +375,7 @@ fn finite_immediate_replies_use_retained_drain_deadline() {
fn no_test_target_completion() {
let server = start_server(ServerBehavior::NoTest, None);
let mut config = config(ManagedPacing::Staggered);
config.client.run_mode = RunMode::NoTest;
config.client.request.run_mode = RunMode::NoTest;
let (task, _) = ManagedClient::task(config, vec![target("no-test", server.addr)]).unwrap();
let outcome = runtime().block_on(task);
let records = server.finish();
@@ -355,9 +391,7 @@ fn authenticated_peer_close_outcome() {
let key = b"managed-peer-close".to_vec();
let server = start_server(ServerBehavior::PeerClose, Some(key.clone()));
let mut configured = target("peer", server.addr);
configured.auth = Some(ClientAuthConfig {
hmac_key: Some(key),
});
configured.auth = TargetAuth::Override(Authentication::Hmac(HmacKey::new(key)));
let (task, _) =
ManagedClient::task(config(ManagedPacing::Staggered), vec![configured]).unwrap();
let outcome = runtime().block_on(task);
@@ -412,15 +446,13 @@ fn pending_limit_failure_drains_reply_and_closes_session() {
Some(key.clone()),
);
let mut managed = config(ManagedPacing::Staggered);
managed.client.duration = Some(Duration::from_millis(250));
managed.client.interval = Duration::from_millis(20);
managed.client.request.duration = Some(Duration::from_millis(250));
managed.client.request.interval = Duration::from_millis(20);
managed.client.probe_timeout = Duration::from_secs(2);
managed.client.max_pending_probes = 1;
managed.final_drain = Duration::from_millis(50);
let mut configured = target("one", server.addr);
configured.auth = Some(ClientAuthConfig {
hmac_key: Some(key),
});
configured.auth = TargetAuth::Override(Authentication::Hmac(HmacKey::new(key)));
let (task, _) = ManagedClient::task(managed, vec![configured]).unwrap();
let outcome = runtime().block_on(async {
tokio::time::timeout(Duration::from_secs(1), task)
@@ -451,6 +483,12 @@ fn event_loss_independence() {
let mut config = config(ManagedPacing::Staggered);
config.event_capacity = 1;
let (task, handle) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap();
let initial = handle.status();
assert_eq!(
initial.targets[0].lifecycle,
ManagedTargetLifecycle::Pending
);
assert!(initial.targets[0].outcome.is_none());
let mut events = handle.subscribe().unwrap();
let outcome = runtime().block_on(task);
assert!(matches!(
@@ -458,7 +496,35 @@ fn event_loss_independence() {
Err(broadcast::error::TryRecvError::Lagged(_))
));
assert_eq!(outcome.successful_target_outcomes, 1);
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Completed);
let status = handle.status();
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
assert!(!status.stop_requested);
let mut receipt = Box::pin(handle.stop());
assert!(poll_once(receipt.as_mut()).is_ready());
assert!(Arc::ptr_eq(&status, &handle.status()));
assert_eq!(status.targets.len(), 1);
let target = &status.targets[0];
assert!(target.desired);
assert_eq!(target.lifecycle, ManagedTargetLifecycle::Terminal);
let terminal = target
.outcome
.as_ref()
.expect("terminal details are durable");
assert_eq!(terminal.target, target.target);
assert_eq!(status.terminal_target_count, 1);
assert_eq!(status.total_target_outcomes, 1);
assert_eq!(status.successful_target_outcomes, 1);
assert_eq!(status.failed_target_outcomes, 0);
assert_eq!(status.peer_closed_target_outcomes, 0);
assert_eq!(status.discarded_target_outcomes, 0);
assert_eq!(
status.recent_target_outcomes.as_ref(),
&[terminal.as_ref().clone()]
);
assert_eq!(
status.recent_target_outcomes,
outcome.recent_target_outcomes
);
server.finish();
}
@@ -478,16 +544,32 @@ fn dropping_all_handles_does_not_stop() {
#[test]
fn task_drop_abandonment() {
let (task, handle) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![target("one", "127.0.0.1:9".parse().unwrap())],
)
.unwrap();
let mut events = handle.subscribe().unwrap();
drop(task);
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
assert_eq!(handle.status().total_target_outcomes, 0);
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
// A latched but unobserved stop must not turn abandonment into completion.
for request_stop in [false, true] {
let (task, handle) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![target("one", "127.0.0.1:9".parse().unwrap())],
)
.unwrap();
let mut events = handle.subscribe().unwrap();
let mut receipt = request_stop.then(|| Box::pin(handle.stop()));
if let Some(receipt) = &mut receipt {
assert!(poll_once(receipt.as_mut()).is_pending());
}
drop(task);
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
assert_eq!(handle.status().total_target_outcomes, 0);
assert!(!handle.status().stop_requested);
assert!(handle.status().final_outcome.is_none());
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
if let Some(receipt) = &mut receipt {
assert!(poll_once(receipt.as_mut()).is_ready());
}
let mut late_stop = Box::pin(handle.stop());
assert!(poll_once(late_stop.as_mut()).is_ready());
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
assert!(!handle.status().stop_requested);
}
}
#[test]
@@ -10,7 +10,8 @@
use std::time::Duration;
use irtt_client::{
AsyncClient, ClientConfig, NegotiationPolicy, NegotiationRestriction, OpenOutcome, SocketConfig,
AsyncClient, ClientConfig, NegotiationChange, NegotiationPolicy, OpenOutcome, OpenPolicy,
SessionRequest,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use irtt_server::{Server, ServerConfig, TimestampAllowance};
@@ -39,22 +40,25 @@ async fn exercise_timestamp_restriction() {
let mut loose = client(server_addr, NegotiationPolicy::Loose).await;
let negotiated = match loose.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => negotiated,
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(
negotiated.params.stamp_at,
negotiated.accepted.stamp_at,
StampAt::Midpoint,
"a request for both instants is answered with the midpoint"
);
assert_eq!(
negotiated.params.clock,
negotiated.accepted.clock,
Clock::Both,
"the clock is not restricted with it"
);
assert!(negotiated
.restrictions
.contains(&NegotiationRestriction::StampAtChanged {
.changes
.contains(&NegotiationChange::StampAtChanged {
requested: StampAt::Both,
negotiated: StampAt::Midpoint,
}));
@@ -84,13 +88,19 @@ async fn exercise_dscp_restriction() {
// The client's configuration is a DSCP codepoint; 46 (EF) is raw byte 184 on
// the wire, and this test does not change either convention.
let with_dscp = |policy| ClientConfig {
dscp: 46,
..test_config(server_addr, policy)
request: SessionRequest {
dscp: 46,
..test_config(policy).request
},
..test_config(policy)
};
let mut strict = AsyncClient::connect(with_dscp(NegotiationPolicy::Strict))
.await
.unwrap();
let mut strict = AsyncClient::connect(
server_addr.to_string(),
with_dscp(NegotiationPolicy::Strict),
)
.await
.unwrap();
let rejected = strict
.open()
.await
@@ -100,20 +110,24 @@ async fn exercise_dscp_restriction() {
"the restriction names DSCP: {rejected}"
);
let mut loose = AsyncClient::connect(with_dscp(NegotiationPolicy::Loose))
.await
.unwrap();
let mut loose =
AsyncClient::connect(server_addr.to_string(), with_dscp(NegotiationPolicy::Loose))
.await
.unwrap();
let negotiated = match loose.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => negotiated,
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(
negotiated.params.dscp, 0,
"the raw wire parameter is negotiated to zero"
negotiated.accepted.dscp, 0,
"the accepted DSCP codepoint is zero"
);
assert!(negotiated
.restrictions
.contains(&NegotiationRestriction::DscpChanged {
.changes
.contains(&NegotiationChange::DscpChanged {
requested: 46,
negotiated: 0,
}));
@@ -138,21 +152,34 @@ async fn a_default_server_restricts_nothing_an_ordinary_client_asks_for() {
async fn exercise_default_policy() {
let (server_addr, shutdown_tx, server_task) = serve(ServerConfig::default()).await;
let mut client = AsyncClient::connect(ClientConfig {
dscp: 46,
..test_config(server_addr, NegotiationPolicy::Strict)
})
let mut client = AsyncClient::connect(
server_addr.to_string(),
ClientConfig {
request: SessionRequest {
dscp: 46,
..test_config(NegotiationPolicy::Strict).request
},
..test_config(NegotiationPolicy::Strict)
},
)
.await
.unwrap();
let negotiated = match client.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => negotiated,
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(negotiated.restrictions, Vec::new());
assert_eq!(negotiated.params.stamp_at, StampAt::Both);
assert_eq!(negotiated.params.clock, Clock::Both);
assert_eq!(negotiated.params.dscp, 184, "the raw byte for codepoint 46");
assert_eq!(negotiated.changes, Vec::new());
assert_eq!(negotiated.accepted.stamp_at, StampAt::Both);
assert_eq!(negotiated.accepted.clock, Clock::Both);
assert_eq!(negotiated.accepted.dscp, 46);
assert_eq!(
negotiated.peer_params.dscp, 184,
"the raw byte for codepoint 46"
);
probe(&mut client).await;
client.close().await.unwrap();
@@ -186,28 +213,29 @@ async fn serve(
}
/// A short deterministic client requesting both timestamps from both clocks.
fn test_config(server_addr: std::net::SocketAddr, policy: NegotiationPolicy) -> ClientConfig {
fn test_config(policy: NegotiationPolicy) -> ClientConfig {
ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
negotiation_policy: policy,
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..SocketConfig::default()
open: OpenPolicy {
negotiation: policy,
timeouts: vec![Duration::from_millis(200)],
},
request: SessionRequest {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Default::default()
},
probe_timeout: Duration::from_millis(200),
..ClientConfig::default()
}
}
async fn client(server_addr: std::net::SocketAddr, policy: NegotiationPolicy) -> AsyncClient {
AsyncClient::connect(test_config(server_addr, policy))
AsyncClient::connect(server_addr.to_string(), test_config(policy))
.await
.unwrap()
}
@@ -1,3 +1,4 @@
use irtt_client::{Authentication, HmacKey, OpenPolicy};
mod support;
mod protocol_options {
pub mod backend_smoke;
@@ -6,8 +7,8 @@ mod protocol_options {
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
RttSample, ServerTiming, SignedDuration,
AcceptedSessionParameters, Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample,
ReceivedStatsSample, RttSample, ServerTiming, SignedDuration,
};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
@@ -146,10 +147,10 @@ fn hmac_rich_mode_uses_negotiated_echo_layout_and_decodes_reply() {
let params = params_for_modes(ReceivedStats::Both, StampAt::Both, Clock::Both);
let config_params = params.clone();
let config_key = key.clone();
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), |addr| {
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), || {
ClientConfig {
hmac_key: Some(config_key),
..config_for_params(addr, &config_params)
auth: Authentication::Hmac(HmacKey::new(config_key)),
..config_for_params(&config_params)
}
});
assert_negotiated_echo_use(&run, &params, true);
@@ -170,9 +171,11 @@ fn strict_open_rejects_changed_compatibility_params_from_server() {
for returned in changed_compatibility_params(&requested) {
let server = start_open_server(returned, None);
let mut config = config_for_params(server.addr, &requested);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let config = config_for_params(&requested);
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
@@ -198,15 +201,31 @@ fn loose_open_uses_returned_params_for_echo_layout_and_reply_parsing() {
returned.server_fill = None;
let requested_for_config = requested.clone();
let run = run_one_probe_with_config(returned.clone(), standard_timestamps(), None, |addr| {
let run = run_one_probe_with_config(returned.clone(), standard_timestamps(), None, || {
ClientConfig {
negotiation_policy: irtt_client::NegotiationPolicy::Loose,
..config_for_params(addr, &requested_for_config)
open: OpenPolicy {
negotiation: irtt_client::NegotiationPolicy::Loose,
..config_for_params(&requested_for_config).open
},
..config_for_params(&requested_for_config)
}
});
assert_eq!(open_params(&run), &requested);
assert_eq!(run.negotiated.params, returned);
assert_eq!(run.negotiated.peer_params, returned);
assert_eq!(
run.negotiated.accepted,
AcceptedSessionParameters {
duration: Some(Duration::from_secs(3)),
interval: Duration::from_secs(1),
length: 28,
received_stats: ReceivedStats::Count,
stamp_at: StampAt::Receive,
clock: Clock::Wall,
dscp: 0,
server_fill: None,
}
);
assert_echo_uses_params(&run, &returned, false);
let reply = expect_echo_reply(&run.reply);
@@ -247,7 +266,7 @@ fn changed_compatibility_params(requested: &Params) -> Vec<Params> {
}
fn assert_negotiated_echo_use(run: &OneProbeRun, params: &Params, hmac: bool) {
assert_eq!(run.negotiated.params, *params);
assert_eq!(run.negotiated.peer_params, *params);
assert_eq!(open_params(run), params);
assert_echo_uses_params(run, params, hmac);
}
@@ -1,5 +1,6 @@
use irtt_client::{Client, ClientEvent};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use std::time::Duration;
use crate::support::{config_for_params, default_params, params_for_modes, BackendPeer};
@@ -7,13 +8,16 @@ use crate::support::{config_for_params, default_params, params_for_modes, Backen
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let mut client = Client::connect(peer.addr().to_string(), config_for_params(&params)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent.seq, 0);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
@@ -32,7 +36,11 @@ fn backend_received_stats_smoke() {
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let mut client =
Client::connect(peer.addr().to_string(), config_for_params(&params)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
client.open().unwrap();
client.send_probe().unwrap();
@@ -55,7 +63,11 @@ fn backend_timestamp_smoke() {
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let mut client =
Client::connect(peer.addr().to_string(), config_for_params(&params)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
client.open().unwrap();
client.send_probe().unwrap();
@@ -102,7 +114,11 @@ fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let mut client =
Client::connect(peer.addr().to_string(), config_for_params(&params)).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
client.open().unwrap();
client.send_probe().unwrap();
@@ -12,7 +12,9 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiatedParams, OpenOutcome, SocketConfig};
use irtt_client::{
Client, ClientConfig, ClientEvent, NegotiationResult, OpenOutcome, OpenPolicy, SessionRequest,
};
use irtt_proto::{
compute_hmac_in_place, decode_request, echo_packet_len, encode_echo_reply, encode_open_reply,
flags, verify_packet_hmac, Clock, DecodedRequest, DecodedRequestKind, EchoReply, OpenReply,
@@ -136,7 +138,7 @@ pub enum ServerObservation {
}
pub struct OneProbeRun {
pub negotiated: NegotiatedParams,
pub negotiated: NegotiationResult,
pub sent: ClientEvent,
pub reply: ClientEvent,
pub observations: Vec<ServerObservation>,
@@ -166,12 +168,12 @@ pub fn params_for_modes(received_stats: ReceivedStats, stamp_at: StampAt, clock:
/// Builds a [`ClientConfig`] whose negotiated `Params` will match `params`.
///
/// `params.dscp` is the raw IP TOS / Traffic Class byte, while
/// [`ClientConfig::dscp`] is the six-bit codepoint that produces it; shift
/// [`SessionRequest::dscp`] is the six-bit codepoint that produces it; shift
/// right by two to recover the codepoint the resulting config must request.
/// Panics if `params.dscp` has any ECN bits set, since those cannot be
/// recovered from a codepoint and this helper's contract would silently stop
/// holding otherwise.
pub fn config_for_params(addr: SocketAddr, params: &Params) -> ClientConfig {
pub fn config_for_params(params: &Params) -> ClientConfig {
let traffic_class =
u8::try_from(params.dscp).expect("test DSCP must fit the raw traffic-class byte");
assert_eq!(
@@ -180,26 +182,27 @@ pub fn config_for_params(addr: SocketAddr, params: &Params) -> ClientConfig {
"test Params::dscp must be representable by ClientConfig's DSCP codepoint"
);
ClientConfig {
server_addr: addr.to_string(),
duration: if params.duration_ns == 0 {
None
} else {
Some(Duration::from_nanos(
u64::try_from(params.duration_ns).expect("test duration must be non-negative"),
))
open: OpenPolicy {
timeouts: vec![Duration::from_millis(200)],
..Default::default()
},
interval: Duration::from_nanos(
u64::try_from(params.interval_ns).expect("test interval must be non-negative"),
),
length: u32::try_from(params.length).unwrap(),
received_stats: params.received_stats,
stamp_at: params.stamp_at,
clock: params.clock,
dscp: traffic_class >> 2,
server_fill: params.server_fill.as_ref().map(|fill| fill.value.clone()),
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(500)),
request: SessionRequest {
duration: if params.duration_ns == 0 {
None
} else {
Some(Duration::from_nanos(
u64::try_from(params.duration_ns).expect("test duration must be non-negative"),
))
},
interval: Duration::from_nanos(
u64::try_from(params.interval_ns).expect("test interval must be non-negative"),
),
length: u32::try_from(params.length).unwrap(),
received_stats: params.received_stats,
stamp_at: params.stamp_at,
clock: params.clock,
dscp: traffic_class >> 2,
server_fill: params.server_fill.as_ref().map(|fill| fill.value.clone()),
..Default::default()
},
..ClientConfig::default()
@@ -219,8 +222,8 @@ pub fn standard_timestamps() -> TimestampFields {
pub fn run_one_probe(params: Params, timestamps: TimestampFields) -> OneProbeRun {
let config_params = params.clone();
run_one_probe_with_config(params, timestamps, None, |addr| {
config_for_params(addr, &config_params)
run_one_probe_with_config(params, timestamps, None, || {
config_for_params(&config_params)
})
}
@@ -231,14 +234,16 @@ pub fn run_one_probe_with_config<F>(
build_config: F,
) -> OneProbeRun
where
F: FnOnce(SocketAddr) -> ClientConfig,
F: FnOnce() -> ClientConfig,
{
let server = start_one_probe_server(server_params, timestamps, hmac_key);
let mut client = Client::connect(build_config(server.addr)).unwrap();
let mut client = Client::connect(server.addr.to_string(), build_config()).unwrap();
client
.set_recv_timeout(Some(Duration::from_millis(500)))
.unwrap();
let negotiated = assert_started(client.open().unwrap());
let sent_events = client.send_probe().unwrap();
assert_eq!(sent_events.len(), 1);
let receipt = client.send_probe().unwrap();
let reply_events = client.recv_once().unwrap();
assert_eq!(reply_events.len(), 1);
@@ -248,7 +253,7 @@ where
OneProbeRun {
negotiated,
sent: sent_events.into_iter().next().unwrap(),
sent: receipt.into(),
reply: reply_events.into_iter().next().unwrap(),
observations,
}
@@ -508,10 +513,13 @@ fn recv_request_timeout(socket: &UdpSocket) -> Option<Vec<u8>> {
.map(|(size, _)| buf[..size].to_vec())
}
fn assert_started(outcome: OpenOutcome) -> NegotiatedParams {
fn assert_started(outcome: OpenOutcome) -> NegotiationResult {
match outcome {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("expected started outcome"),
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => negotiated,
OpenOutcome::NoTestCompleted(_) => panic!("expected started outcome"),
}
}
@@ -14,7 +14,9 @@
use std::{io, net::SocketAddr, time::Duration};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome, PacketMeta, SocketConfig};
use irtt_client::{
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, PacketMeta, SessionRequest,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use irtt_server::{Server, ServerConfig, ServerRuntimeError};
use tokio::{sync::oneshot, task::JoinHandle, time::timeout};
@@ -193,30 +195,37 @@ impl OpenSession {
/// A client with an open session requesting `dscp` as a codepoint.
async fn opened_session(server_addr: SocketAddr, dscp: u8) -> OpenSession {
let config = ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(30)),
interval: Duration::from_millis(10),
length: 64,
dscp,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
open_timeouts: vec![Duration::from_millis(500)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(500)),
..SocketConfig::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(500)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_secs(30)),
interval: Duration::from_millis(10),
length: 64,
dscp,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Default::default()
},
probe_timeout: Duration::from_millis(500),
..ClientConfig::default()
};
let mut client = AsyncClient::connect(config).await.unwrap();
let mut client = AsyncClient::connect(server_addr.to_string(), config)
.await
.unwrap();
match client.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => assert_eq!(
negotiated.params.dscp,
OpenOutcome::Started(irtt_client::SessionStarted {
negotiation: negotiated,
..
}) => assert_eq!(
negotiated.peer_params.dscp,
i64::from(dscp) << 2,
"the server negotiates the raw byte the codepoint asks for"
),
OpenOutcome::NoTestCompleted { .. } => panic!("a normal client unexpectedly ran no-test"),
OpenOutcome::NoTestCompleted(_) => panic!("a normal client unexpectedly ran no-test"),
}
OpenSession {
client,
@@ -18,7 +18,9 @@ use std::{
time::Duration,
};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome, PacketMeta, SocketConfig};
use irtt_client::{
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, PacketMeta, SessionRequest,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use irtt_server::{Server, ServerConfig, ServerRuntimeError};
use tokio::{sync::oneshot, task::JoinHandle, time::timeout};
@@ -193,32 +195,36 @@ fn unthrottled() -> ServerConfig {
async fn connected_client(server_addr: SocketAddr) -> AsyncClient {
let config = ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(30)),
interval: Duration::from_millis(10),
length: 64,
dscp: EF_DSCP,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
open_timeouts: vec![Duration::from_millis(500)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(500)),
..SocketConfig::default()
open: OpenPolicy {
timeouts: vec![Duration::from_millis(500)],
..Default::default()
},
request: SessionRequest {
duration: Some(Duration::from_secs(30)),
interval: Duration::from_millis(10),
length: 64,
dscp: EF_DSCP,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Default::default()
},
probe_timeout: Duration::from_millis(500),
..ClientConfig::default()
};
AsyncClient::connect(config).await.unwrap()
AsyncClient::connect(server_addr.to_string(), config)
.await
.unwrap()
}
async fn assert_started(client: &mut AsyncClient, server_addr: SocketAddr) {
match client.open().await.unwrap() {
OpenOutcome::Started { remote, token, .. } => {
OpenOutcome::Started(irtt_client::SessionStarted { remote, token, .. }) => {
assert_eq!(remote, server_addr);
assert_ne!(token, 0);
}
OpenOutcome::NoTestCompleted { .. } => panic!("a normal client unexpectedly ran no-test"),
OpenOutcome::NoTestCompleted(_) => panic!("a normal client unexpectedly ran no-test"),
}
}
-169
View File
@@ -304,172 +304,3 @@ fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
*pos += TIMESTAMP_SIZE;
value
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
hmac,
params::{Clock, ReceivedStats, StampAt},
write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
};
fn default_params() -> Params {
Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
}
}
fn params_with_payload_space(payload_space: usize) -> Params {
let header_len = PacketLayout::echo(false, &Params::default()).header_len();
Params {
length: (header_len + payload_space) as i64,
..Params::default()
}
}
/// Zero-fills every negotiated reply field, matching what a peer that
/// reports no optional values would put on the wire.
fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
if layout.recv_count {
out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
}
if layout.recv_window {
out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
}
for _ in 0..layout.timestamp_count() {
out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
}
}
#[test]
fn echo_reply_decodes_default_fields() {
let packet = [
0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
0x00, 0x00, 0x00, 0x00,
];
let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.sequence, 2);
assert_eq!(reply.recv_count, Some(3));
assert_eq!(reply.recv_window, Some(7));
assert!(reply.timestamps.recv_wall.is_some());
assert!(reply.timestamps.midpoint_wall.is_none());
}
#[test]
fn echo_reply_decodes_exact_negotiated_length_with_payload() {
let params = params_with_payload_space(4);
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
packet.extend_from_slice(&[1, 2, 3, 4]);
let reply = decode_echo_reply(&packet, &params, None).unwrap();
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
}
#[test]
fn echo_reply_rejects_shorter_than_negotiated_length() {
let params = params_with_payload_space(4);
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
assert_eq!(
decode_echo_reply(&packet, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: 20,
actual: 16,
})
);
}
#[test]
fn echo_reply_rejects_longer_than_negotiated_length() {
let params = Params::default();
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
packet.push(0);
assert_eq!(
decode_echo_reply(&packet, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: 16,
actual: 17,
})
);
}
#[test]
fn hmac_echo_reply_decodes_default_fields_after_hmac() {
let params = default_params();
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(layout.header_len());
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
push_zeroed_layout_tail(layout, &mut packet);
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
assert_eq!(packet.len(), 76);
let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.sequence, 2);
assert_eq!(reply.recv_count, Some(0));
assert_eq!(reply.recv_window, Some(0));
assert_eq!(reply.payload.len(), 0);
}
#[test]
fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
let mut params = Params {
length: 48,
..Params::default()
};
params.received_stats = ReceivedStats::Both;
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(echo_packet_len(true, &params).unwrap());
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
push_zeroed_layout_tail(layout, &mut packet);
packet.extend_from_slice(&[1, 2, 3, 4]);
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
}
#[test]
fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
let params = Params::default();
let mut packet = Vec::new();
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&2_u32.to_le_bytes());
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
packet.push(0);
assert_eq!(
decode_echo_reply(&packet, &params, Some(b"testkey")),
Err(ProtoError::PacketLengthMismatch {
expected: 32,
actual: 33,
})
);
}
}
-98
View File
@@ -136,101 +136,3 @@ fn with_hmac_flag(flags: u8, authenticated: bool) -> u8 {
flags & !FLAG_HMAC
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
flags::{FLAG_OPEN, FLAG_REPLY},
MAGIC,
};
const KEY: &[u8] = b"testkey";
const OPEN_REQUEST_RULES: &[FlagRule] =
&[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)];
fn header(flags: u8) -> Vec<u8> {
let mut packet = MAGIC.to_vec();
packet.push(flags);
packet
}
#[test]
fn decode_rejects_bad_magic_and_reserved_flags() {
assert_eq!(
decode(&[0, MAGIC[1], MAGIC[2], FLAG_OPEN], None, &[]),
Err(ProtoError::BadMagic)
);
assert_eq!(
decode(&header(FLAG_OPEN | 0x10), None, &[]),
Err(ProtoError::ReservedFlags(0x10))
);
}
#[test]
fn decode_enforces_required_and_rejected_flags() {
assert_eq!(
decode(&header(0), None, OPEN_REQUEST_RULES),
Err(ProtoError::MissingFlag(FLAG_OPEN))
);
assert_eq!(
decode(&header(FLAG_OPEN | FLAG_REPLY), None, OPEN_REQUEST_RULES),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
);
}
#[test]
fn decode_rejects_hmac_presence_mismatch() {
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
authenticated.extend_from_slice(&[0; HMAC_SIZE]);
assert_eq!(
decode(&authenticated, None, OPEN_REQUEST_RULES),
Err(ProtoError::HmacPresenceMismatch)
);
assert_eq!(
decode(&header(FLAG_OPEN), Some(KEY), OPEN_REQUEST_RULES),
Err(ProtoError::HmacPresenceMismatch)
);
}
#[test]
fn structural_decode_reports_hmac_presence_without_a_key() {
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
authenticated.extend_from_slice(&[0xff; HMAC_SIZE]);
let envelope = decode_structural(&authenticated).unwrap();
assert!(envelope.hmac_present);
assert_eq!(envelope.body_offset, HEADER_SIZE + HMAC_SIZE);
let envelope = decode_structural(&header(FLAG_OPEN)).unwrap();
assert!(!envelope.hmac_present);
assert_eq!(envelope.body_offset, HEADER_SIZE);
}
#[test]
fn verify_rejects_bad_hmac() {
let mut packet = begin_checked(FLAG_OPEN, Some(KEY), OPEN_REQUEST_RULES, 22).unwrap();
packet.extend_from_slice(&[1, 2]);
let mut packet = finish(packet, Some(KEY)).unwrap();
*packet.last_mut().unwrap() ^= 0x80;
decode(&packet, Some(KEY), OPEN_REQUEST_RULES).unwrap();
assert_eq!(verify(&packet, Some(KEY)), Err(ProtoError::BadHmac));
}
#[test]
fn encoder_key_authoritatively_normalizes_hmac_flag() {
let plain =
begin_checked(FLAG_OPEN | FLAG_HMAC, None, OPEN_REQUEST_RULES, HEADER_SIZE).unwrap();
assert_eq!(plain[3], FLAG_OPEN);
let authenticated = begin_checked(
FLAG_OPEN,
Some(KEY),
OPEN_REQUEST_RULES,
HEADER_SIZE + HMAC_SIZE,
)
.unwrap();
assert_eq!(authenticated[3], FLAG_OPEN | FLAG_HMAC);
}
}
-73
View File
@@ -96,76 +96,3 @@ pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
pub(crate) fn hmac_offset() -> usize {
HEADER_SIZE
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
flags::{FLAG_HMAC, FLAG_OPEN},
MAGIC,
};
fn reference_copy_zero_hmac(
key: &[u8],
packet: &[u8],
hmac_offset: usize,
) -> Result<[u8; HMAC_SIZE]> {
if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE {
return Err(ProtoError::InvalidHmacOffset);
}
let mut copy = packet.to_vec();
copy[hmac_offset..hmac_offset + HMAC_SIZE].fill(0);
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC accepts keys of any size");
mac.update(&copy);
let bytes = mac.finalize().into_bytes();
let mut out = [0u8; HMAC_SIZE];
out.copy_from_slice(&bytes);
Ok(out)
}
fn packet_with_hmac_field(hmac_bytes: [u8; HMAC_SIZE]) -> Vec<u8> {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN | FLAG_HMAC);
packet.extend_from_slice(&hmac_bytes);
packet.extend_from_slice(&[1, 2, 3, 4]);
packet
}
#[test]
fn compute_and_verify_hmac() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(
verify_hmac(b"wrong", &packet, hmac_offset()),
Err(ProtoError::BadHmac)
);
}
#[test]
fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
let reference = reference_copy_zero_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(digest, reference);
}
#[test]
fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
packet[hmac_offset() + HMAC_SIZE + 1] ^= 0x01;
assert_eq!(
verify_hmac(b"testkey", &packet, hmac_offset()),
Err(ProtoError::BadHmac)
);
}
}
-191
View File
@@ -162,194 +162,3 @@ pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
};
Ok(header_len.max(requested))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{Clock, ReceivedStats, StampAt};
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
Params {
received_stats: stats,
stamp_at,
clock,
..Params::default()
}
}
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
let stats_len = match stats {
ReceivedStats::None => 0,
ReceivedStats::Count => RECV_COUNT_SIZE,
ReceivedStats::Window => RECV_WINDOW_SIZE,
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
let timestamp_groups = match stamp_at {
StampAt::None => 0,
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
StampAt::Both => 2,
};
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
}
#[test]
fn verified_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::None, StampAt::None, Clock::Both)
),
16
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Count, StampAt::Send, Clock::Wall)
),
28
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Window, StampAt::Receive, Clock::Monotonic)
),
32
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Both, StampAt::Midpoint, Clock::Both)
),
44
);
assert_eq!(
echo_header_len(
false,
&params(ReceivedStats::Both, StampAt::Both, Clock::Both)
),
60
);
assert_eq!(
echo_header_len(
true,
&params(ReceivedStats::Both, StampAt::Both, Clock::Both)
),
76
);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for hmac in [false, true] {
let params = params(stats, stamp_at, clock);
let layout = PacketLayout::echo(hmac, &params);
let expected_len = HEADER_SIZE
+ TOKEN_SIZE
+ SEQ_SIZE
+ if hmac { HMAC_SIZE } else { 0 }
+ expected_optional_len(stats, stamp_at, clock);
assert_eq!(
layout.header_len(),
expected_len,
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
);
assert_eq!(layout.recv_count, stats.has_count());
assert_eq!(layout.recv_window, stats.has_window());
assert_eq!(
layout.recv_wall,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& clock.has_wall()
);
assert_eq!(
layout.recv_mono,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& clock.has_mono()
);
assert_eq!(
layout.midpoint_wall,
matches!(stamp_at, StampAt::Midpoint) && clock.has_wall()
);
assert_eq!(
layout.midpoint_mono,
matches!(stamp_at, StampAt::Midpoint) && clock.has_mono()
);
assert_eq!(
layout.send_wall,
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_wall()
);
assert_eq!(
layout.send_mono,
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_mono()
);
if hmac {
assert_eq!(
echo_header_len(true, &params) - echo_header_len(false, &params),
HMAC_SIZE
);
}
}
}
}
}
}
#[test]
fn negotiated_length_is_floored_at_the_required_field_block() {
// The 60-byte header of this layout is the floor. A negative negotiated
// length is not an error: it asks for nothing beyond the mandatory
// fields, exactly as zero does.
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
p.length = length;
assert_eq!(
echo_packet_len(false, &p),
Ok(expected),
"unexpected packet length for negotiated length {length}"
);
}
}
/// A positive length wider than `usize` must stay an error rather than
/// becoming a saturated buffer size. Only reachable where `usize` is
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
#[cfg(target_pointer_width = "32")]
#[test]
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 5_000_000_000;
assert_eq!(
echo_packet_len(false, &p),
Err(ProtoError::PacketLengthUnrepresentable {
length: 5_000_000_000
})
);
}
}
-50
View File
@@ -98,53 +98,3 @@ pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenR
params: Params::decode(&packet[pos..])?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
hmac,
params::{Clock, ReceivedStats, StampAt},
FLAG_HMAC, HMAC_SIZE,
};
fn default_params() -> Params {
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
}
}
#[test]
fn open_reply_decodes_token() {
let mut packet = vec![0x14, 0xa7, 0x5b, 0x03];
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&default_params().encode());
let reply = decode_open_reply(&packet, None).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.params, default_params());
}
#[test]
fn hmac_open_reply_decodes_token_after_hmac_field() {
let mut packet = vec![0x14, 0xa7, 0x5b, FLAG_OPEN | FLAG_REPLY | FLAG_HMAC];
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
packet.extend_from_slice(&default_params().encode());
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
assert_eq!(
packet.len(),
4 + HMAC_SIZE + TOKEN_SIZE + default_params().encode().len()
);
let reply = decode_open_reply(&packet, Some(b"testkey")).unwrap();
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
assert_eq!(reply.params, default_params());
}
}
-770
View File
@@ -368,773 +368,3 @@ fn read_int(input: &[u8], pos: &mut usize) -> Result<i64> {
*pos += used;
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_round_trip(params: Params) {
assert_eq!(Params::decode(&params.encode()), Ok(params));
}
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(value.len() as u64, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(tag, &mut encoded);
varint::encode_varint(value, &mut encoded);
encoded
}
#[test]
fn params_round_trip() {
let params = Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
server_fill: Some(ServerFill {
value: "pattern:abc".to_owned(),
}),
};
assert_round_trip(params);
}
#[test]
fn params_round_trip_negotiated_option_modes() {
for received_stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
assert_round_trip(Params {
protocol_version: 1,
received_stats,
..Params::default()
});
}
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
assert_round_trip(Params {
protocol_version: 1,
stamp_at,
clock: Clock::Both,
..Params::default()
});
}
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params {
protocol_version: 1,
stamp_at: StampAt::Both,
clock,
..Params::default()
});
}
}
#[test]
fn params_round_trip_dscp_values_are_encoded_without_shifting() {
// `Params::dscp` is a raw wire byte; encode/decode carry whatever
// value is set without interpreting or shifting it, regardless of
// whether it happens to look like a codepoint (46) or a raw TOS byte
// (184).
for dscp in [0, 46, 63, 64, 184, -1] {
let params = Params {
protocol_version: 1,
dscp,
..Params::default()
};
assert_round_trip(params);
}
let params = Params {
protocol_version: 1,
dscp: 46,
..Params::default()
};
let encoded = params.encode();
assert!(
encoded.windows(2).any(|bytes| bytes == [8, 92]),
"dscp value 46 must be encoded as param value 46"
);
assert!(
!encoded.windows(3).any(|bytes| bytes == [8, 0xf0, 0x02]),
"Params::encode must not shift dscp 46 to 184; any codepoint shift is a caller concern"
);
assert_eq!(Params::decode(&encoded).unwrap().dscp, 46);
}
#[test]
fn server_fill_absent_short_and_max_length_round_trip() {
assert_round_trip(Params {
protocol_version: 1,
server_fill: None,
..Params::default()
});
assert_round_trip(Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
..Params::default()
});
assert_round_trip(Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
}),
..Params::default()
});
}
#[test]
fn server_fill_decode_accepts_max_length() {
let value = b"0123456789abcdef0123456789abcdef";
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
assert_eq!(
params.server_fill,
Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
})
);
}
#[test]
fn server_fill_decode_rejects_oversized_length() {
let value = b"0123456789abcdef0123456789abcdefx";
assert_eq!(
Params::decode(&encode_server_fill_value(value)),
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
);
}
#[test]
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
let params = Params {
protocol_version: 1,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
..Params::default()
};
let encoded = params.encode();
assert!(encoded
.windows(6)
.any(|bytes| bytes == [9, 4, b'r', b'a', b'n', b'd']));
}
#[test]
fn unknown_tags_are_ignored() {
let mut encoded = Vec::new();
varint::encode_uvarint(99, &mut encoded);
varint::encode_varint(123, &mut encoded);
varint::encode_uvarint(1, &mut encoded);
varint::encode_varint(1, &mut encoded);
let params = Params::decode(&encoded).unwrap();
assert_eq!(params.protocol_version, 1);
}
#[test]
fn invalid_received_stats_value_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(5, &mut encoded);
varint::encode_varint(4, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "ReceivedStats",
value: 4,
})
);
}
#[test]
fn invalid_timestamp_value_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(6, &mut encoded);
varint::encode_varint(5, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "StampAt",
value: 5,
})
);
}
#[test]
fn explicit_clock_zero_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(7, &mut encoded);
varint::encode_varint(0, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
assert_eq!(
Params::decode_with_presence(&encoded),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
#[test]
fn explicit_clock_values_one_to_three_are_accepted() {
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
assert_eq!(decoded.params.clock, expected);
assert!(decoded.presence.clock);
}
}
#[test]
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
let decoded = Params::decode_with_presence(&[]).unwrap();
assert_eq!(decoded.params, Params::default());
assert_eq!(decoded.params.protocol_version, 0);
assert_eq!(decoded.params.duration_ns, 0);
assert_eq!(decoded.params.interval_ns, 0);
assert_eq!(decoded.params.length, 0);
assert_eq!(decoded.params.dscp, 0);
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
assert_eq!(decoded.params.stamp_at, StampAt::None);
// The wire default clock is zero, not `Both`.
assert_eq!(decoded.params.clock, Clock::Unspecified);
assert!(!decoded.params.clock.has_wall());
assert!(!decoded.params.clock.has_mono());
assert_eq!(decoded.params.server_fill, None);
assert_eq!(decoded.presence, ParamPresence::default());
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: false,
duration_ns: false,
interval_ns: false,
length: false,
received_stats: false,
stamp_at: false,
clock: false,
dscp: false,
server_fill: false,
}
);
}
#[test]
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
// Duration and Interval are the pair a server must tell apart: absent is
// accepted, explicitly zero is not.
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
assert_eq!(absent.params.duration_ns, 0);
assert!(!absent.presence.duration_ns);
assert_eq!(absent.params.interval_ns, 0);
assert!(!absent.presence.interval_ns);
assert_eq!(absent.params.clock, Clock::Unspecified);
assert!(!absent.presence.clock);
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
assert_eq!(explicit_duration.params.duration_ns, 0);
assert!(explicit_duration.presence.duration_ns);
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
assert_eq!(explicit_interval.params.interval_ns, 0);
assert!(explicit_interval.presence.interval_ns);
}
#[test]
fn every_known_tag_reports_its_own_presence() {
let mut encoded = Vec::new();
for (tag, value) in [
(1, 1),
(2, 5),
(3, 6),
(4, 7),
(5, 3),
(6, 4),
(7, 3),
(8, 184),
] {
encoded.extend_from_slice(&encode_int(tag, value));
}
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
let decoded = Params::decode_with_presence(&encoded).unwrap();
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: true,
duration_ns: true,
interval_ns: true,
length: true,
received_stats: true,
stamp_at: true,
clock: true,
dscp: true,
server_fill: true,
}
);
}
#[test]
fn unspecified_clock_is_encoded_by_omission() {
let params = Params {
protocol_version: 1,
clock: Clock::Unspecified,
..Params::default()
};
let encoded = params.encode();
assert_eq!(encoded, encode_int(1, 1));
assert!(
!encoded.windows(2).any(|bytes| bytes[0] == 7),
"an unspecified clock must omit the tag rather than encode an explicit zero"
);
assert_round_trip(params);
}
#[test]
fn decode_and_decode_with_presence_agree() {
let mut payloads = vec![
Vec::new(),
encode_int(1, 1),
encode_int(2, 0),
encode_server_fill_value(b"rand"),
];
payloads.push(
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
}
.encode(),
);
// Malformed payloads must fail identically through both entry points.
payloads.push(vec![1, 0x80]);
payloads.push(encode_int(7, 0));
for payload in payloads {
assert_eq!(
Params::decode(&payload),
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
"decode and decode_with_presence disagreed on {payload:02x?}"
);
}
}
#[test]
fn malformed_server_fill_is_rejected() {
let encoded = [9, 4, b'a', b'b'];
assert_eq!(Params::decode(&encoded), Err(ProtoError::MalformedParams));
}
#[test]
fn non_utf8_server_fill_is_rejected() {
let encoded = [9, 1, 0xff];
assert_eq!(Params::decode(&encoded), Err(ProtoError::InvalidUtf8));
}
#[cfg(target_pointer_width = "32")]
#[test]
fn server_fill_length_too_large_for_usize_is_rejected() {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(u64::from(u32::MAX) + 1, &mut encoded);
assert_eq!(
Params::decode(&encoded),
Err(ProtoError::ParameterLengthTooLarge {
tag: 9,
length: u64::from(u32::MAX) + 1,
})
);
}
#[test]
fn truncated_varint_parameter_is_rejected() {
let encoded = [1, 0x80];
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
}
#[test]
fn truncated_unknown_parameter_value_is_rejected() {
let encoded = [99];
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
}
mod properties {
use super::*;
use proptest::prelude::*;
fn received_stats_strategy() -> impl Strategy<Value = ReceivedStats> {
prop_oneof![
Just(ReceivedStats::None),
Just(ReceivedStats::Count),
Just(ReceivedStats::Window),
Just(ReceivedStats::Both),
]
}
fn stamp_at_strategy() -> impl Strategy<Value = StampAt> {
prop_oneof![
Just(StampAt::None),
Just(StampAt::Send),
Just(StampAt::Receive),
Just(StampAt::Both),
Just(StampAt::Midpoint),
]
}
/// A `Clock` valid for direct construction, including `Unspecified`
/// (wire omission). Never produces an explicit zero on the wire; see
/// `explicit_clock_strategy` for that.
fn clock_strategy() -> impl Strategy<Value = Clock> {
prop_oneof![
Just(Clock::Unspecified),
Just(Clock::Wall),
Just(Clock::Monotonic),
Just(Clock::Both),
]
}
/// A `Clock` value valid to encode *explicitly* on the wire (tag 7).
/// Excludes `Unspecified`/zero, which is only ever reached by omission.
fn explicit_clock_value_strategy() -> impl Strategy<Value = i64> {
1_i64..=3
}
fn server_fill_strategy() -> impl Strategy<Value = Option<ServerFill>> {
prop_oneof![
Just(None),
proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some(
ServerFill {
value: chars.into_iter().collect(),
}
)),
]
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
received_stats_strategy(),
stamp_at_strategy(),
clock_strategy(),
server_fill_strategy(),
)
.prop_map(
|(
protocol_version,
duration_ns,
interval_ns,
length,
dscp,
received_stats,
stamp_at,
clock,
server_fill,
)| Params {
protocol_version,
duration_ns,
interval_ns,
length,
received_stats,
stamp_at,
clock,
dscp,
server_fill,
},
)
}
fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec<u8>) {
varint::encode_uvarint(tag, out);
varint::encode_varint(value, out);
}
fn encode_tagged_server_fill(value: &str, out: &mut Vec<u8>) {
varint::encode_uvarint(9, out);
varint::encode_uvarint(value.len() as u64, out);
out.extend_from_slice(value.as_bytes());
}
proptest! {
#[test]
fn valid_params_round_trip(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode(&encoded).unwrap();
prop_assert_eq!(decoded, params);
}
#[test]
fn encoded_param_presence_matches_omission_rules(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0);
prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0);
prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0);
prop_assert_eq!(decoded.presence.length, params.length != 0);
prop_assert_eq!(decoded.presence.dscp, params.dscp != 0);
prop_assert_eq!(
decoded.presence.received_stats,
params.received_stats != ReceivedStats::None
);
prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None);
prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified);
prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some());
}
#[test]
fn repeated_protocol_version_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(1, first, &mut encoded);
encode_tagged_int(1, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.protocol_version, second);
prop_assert!(decoded.presence.protocol_version);
}
#[test]
fn repeated_duration_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(2, first, &mut encoded);
encode_tagged_int(2, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.duration_ns, second);
prop_assert!(decoded.presence.duration_ns);
}
#[test]
fn repeated_interval_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(3, first, &mut encoded);
encode_tagged_int(3, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.interval_ns, second);
prop_assert!(decoded.presence.interval_ns);
}
#[test]
fn repeated_length_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(4, first, &mut encoded);
encode_tagged_int(4, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.length, second);
prop_assert!(decoded.presence.length);
}
#[test]
fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) {
let mut encoded = Vec::new();
encode_tagged_int(5, first, &mut encoded);
encode_tagged_int(5, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap());
prop_assert!(decoded.presence.received_stats);
}
#[test]
fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) {
let mut encoded = Vec::new();
encode_tagged_int(6, first, &mut encoded);
encode_tagged_int(6, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap());
prop_assert!(decoded.presence.stamp_at);
}
#[test]
fn repeated_clock_is_last_wins(
first in explicit_clock_value_strategy(),
second in explicit_clock_value_strategy(),
) {
let mut encoded = Vec::new();
encode_tagged_int(7, first, &mut encoded);
encode_tagged_int(7, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap());
prop_assert!(decoded.presence.clock);
}
#[test]
fn repeated_dscp_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(8, first, &mut encoded);
encode_tagged_int(8, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.dscp, second);
prop_assert!(decoded.presence.dscp);
}
#[test]
fn repeated_server_fill_is_last_wins(
first in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
second in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
) {
let mut encoded = Vec::new();
encode_tagged_server_fill(&first, &mut encoded);
encode_tagged_server_fill(&second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(
decoded.params.server_fill,
Some(ServerFill { value: second })
);
prop_assert!(decoded.presence.server_fill);
}
#[test]
fn unknown_scalar_tag_prefix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut prefixed = Vec::new();
varint::encode_uvarint(unknown_tag, &mut prefixed);
varint::encode_uvarint(unknown_value, &mut prefixed);
prefixed.extend_from_slice(&params.encode());
let decoded = Params::decode(&prefixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
#[test]
fn unknown_scalar_tag_suffix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut suffixed = params.encode();
varint::encode_uvarint(unknown_tag, &mut suffixed);
varint::encode_uvarint(unknown_value, &mut suffixed);
let decoded = Params::decode(&suffixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
}
/// Omitted-vs-explicit-zero presence, for the fields where explicit
/// zero is valid on the wire. `Clock` is deliberately excluded: an
/// explicit zero there is rejected outright, not merely "present with
/// the default value" (see `explicit_clock_zero_is_rejected`).
#[test]
fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() {
let scalar_tags = [1_u64, 2, 3, 4, 8];
for tag in scalar_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
assert_eq!(explicit.params, Params::default());
let (omitted_presence, explicit_presence) = match tag {
1 => (
omitted.presence.protocol_version,
explicit.presence.protocol_version,
),
2 => (omitted.presence.duration_ns, explicit.presence.duration_ns),
3 => (omitted.presence.interval_ns, explicit.presence.interval_ns),
4 => (omitted.presence.length, explicit.presence.length),
8 => (omitted.presence.dscp, explicit.presence.dscp),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
let enum_tags = [5_u64, 6];
for tag in enum_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
let (omitted_presence, explicit_presence) = match tag {
5 => (
omitted.presence.received_stats,
explicit.presence.received_stats,
),
6 => (omitted.presence.stamp_at, explicit.presence.stamp_at),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
// `Clock` deliberately breaks the pattern above: explicit zero is
// rejected rather than accepted-but-present.
let omitted = Params::decode_with_presence(&[]).unwrap();
assert_eq!(omitted.params.clock, Clock::Unspecified);
assert!(!omitted.presence.clock);
let mut explicit_clock_zero = Vec::new();
encode_tagged_int(7, 0, &mut explicit_clock_zero);
assert_eq!(
Params::decode_with_presence(&explicit_clock_zero),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
}
}
-109
View File
@@ -59,112 +59,3 @@ pub fn decode_varint(input: &[u8]) -> Result<(i64, usize)> {
let (value, used) = decode_uvarint(input)?;
Ok((zigzag_decode(value), used))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uvarint_round_trips() {
for value in [
0,
1,
2,
127,
128,
255,
16_384,
u32::MAX as u64,
i64::MAX as u64,
u64::MAX,
] {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn signed_varint_round_trips() {
for value in [
i64::MIN,
-9_223_372_036,
-2,
-1,
0,
1,
2,
1_000_000_000,
3_000_000_000,
i64::MAX,
] {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn verified_byte_examples() {
let cases: &[(i64, &[u8])] = &[
(1, &[0x02]),
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
(1472, &[0x80, 0x17]),
(3, &[0x06]),
(184, &[0xf0, 0x02]),
];
for (value, expected) in cases {
let mut encoded = Vec::new();
encode_varint(*value, &mut encoded);
assert_eq!(&encoded, expected);
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
}
let mut tag = Vec::new();
encode_uvarint(9, &mut tag);
assert_eq!(tag, [0x09]);
tag.clear();
encode_uvarint(24, &mut tag);
assert_eq!(tag, [0x18]);
}
mod properties {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_uvarint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_varint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn zigzag_round_trips_from_signed(value: i64) {
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
#[test]
fn zigzag_round_trips_from_unsigned(value: u64) {
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
use irtt_proto::{compute_hmac, compute_hmac_in_place, verify_hmac, ProtoError};
fn captured_open() -> Vec<u8> {
// Captured vector 6; provenance and independently checked digest are in
// docs/protocol/test-vectors/README.md. This is an oracle for the primitive.
"14 a7 5b 09 ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36
01 02 02 80 d0 ac f3 0e 03 80 a8 d6 b9 07 05 06 06 06 07 06"
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect()
}
#[test]
fn computing_hmac_ignores_existing_digest_and_only_replaces_its_field() {
let expected = captured_open();
let mut packet = expected.clone();
packet[4..20].fill(0xa5);
let before = packet.clone();
assert_eq!(
compute_hmac(b"testkey", &packet, 4).unwrap().as_slice(),
&expected[4..20]
);
assert_eq!(packet, before);
compute_hmac_in_place(b"testkey", &mut packet, 4).unwrap();
assert_eq!(packet, expected);
}
#[test]
fn verification_rejects_wrong_key_and_modified_authenticated_bytes() {
let packet = captured_open();
verify_hmac(b"testkey", &packet, 4).unwrap();
assert_eq!(verify_hmac(b"wrong", &packet, 4), Err(ProtoError::BadHmac));
for offset in [0, 4, 21, packet.len() - 1] {
let mut modified = packet.clone();
modified[offset] ^= 1;
assert_eq!(
verify_hmac(b"testkey", &modified, 4),
Err(ProtoError::BadHmac)
);
}
}
#[test]
fn incomplete_or_out_of_bounds_digest_fields_are_rejected_without_mutation() {
for offset in [5, 20, 21, usize::MAX] {
let mut packet = vec![0xa5; 20];
assert_eq!(
compute_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
verify_hmac(b"key", &packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(
compute_hmac_in_place(b"key", &mut packet, offset),
Err(ProtoError::InvalidHmacOffset)
);
assert_eq!(packet, [0xa5; 20]);
}
}
+165
View File
@@ -0,0 +1,165 @@
use irtt_proto::params::{Clock, ReceivedStats, StampAt};
use irtt_proto::{
echo_header_len, echo_packet_len, PacketLayout, Params, HMAC_SIZE, RECV_COUNT_SIZE,
RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
};
const HEADER_SIZE: usize = 4;
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
Params {
received_stats: stats,
stamp_at,
clock,
..Params::default()
}
}
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
let stats_len = match stats {
ReceivedStats::None => 0,
ReceivedStats::Count => RECV_COUNT_SIZE,
ReceivedStats::Window => RECV_WINDOW_SIZE,
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
let timestamp_groups = match stamp_at {
StampAt::None => 0,
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
StampAt::Both => 2,
};
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
}
#[test]
fn open_and_close_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
for stamp_at in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for hmac in [false, true] {
let params = params(stats, stamp_at, clock);
let layout = PacketLayout::echo(hmac, &params);
let expected_len = HEADER_SIZE
+ TOKEN_SIZE
+ SEQ_SIZE
+ if hmac { HMAC_SIZE } else { 0 }
+ expected_optional_len(stats, stamp_at, clock);
assert_eq!(
layout.header_len(),
expected_len,
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
);
assert_eq!(
layout.recv_count,
matches!(stats, ReceivedStats::Count | ReceivedStats::Both)
);
assert_eq!(
layout.recv_window,
matches!(stats, ReceivedStats::Window | ReceivedStats::Both)
);
assert_eq!(
layout.recv_wall,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.recv_mono,
matches!(stamp_at, StampAt::Receive | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.midpoint_wall,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.midpoint_mono,
matches!(stamp_at, StampAt::Midpoint)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
assert_eq!(
layout.send_wall,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Wall | Clock::Both)
);
assert_eq!(
layout.send_mono,
matches!(stamp_at, StampAt::Send | StampAt::Both)
&& matches!(clock, Clock::Monotonic | Clock::Both)
);
if hmac {
assert_eq!(
echo_header_len(true, &params) - echo_header_len(false, &params),
HMAC_SIZE
);
}
}
}
}
}
}
#[test]
fn negotiated_length_is_floored_at_the_required_field_block() {
// The 60-byte header of this layout is the floor. A negative negotiated
// length is not an error: it asks for nothing beyond the mandatory
// fields, exactly as zero does.
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
p.length = length;
assert_eq!(
echo_packet_len(false, &p),
Ok(expected),
"unexpected packet length for negotiated length {length}"
);
}
}
/// A positive length wider than `usize` must stay an error rather than
/// becoming a saturated buffer size. Only reachable where `usize` is
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
#[cfg(target_pointer_width = "32")]
#[test]
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 5_000_000_000;
assert_eq!(
echo_packet_len(false, &p),
Err(irtt_proto::ProtoError::PacketLengthUnrepresentable {
length: 5_000_000_000
})
);
}
+399
View File
@@ -0,0 +1,399 @@
use irtt_proto::{
varint, Clock, ParamPresence, Params, ProtoError, ReceivedStats, ServerFill, StampAt,
};
use proptest::prelude::*;
fn int(tag: u64, value: i64) -> Vec<u8> {
let mut out = Vec::new();
varint::encode_uvarint(tag, &mut out);
varint::encode_varint(value, &mut out);
out
}
fn fill(value: &[u8]) -> Vec<u8> {
let mut out = vec![9];
varint::encode_uvarint(value.len() as u64, &mut out);
out.extend_from_slice(value);
out
}
fn presence(p: ParamPresence) -> [bool; 9] {
[
p.protocol_version,
p.duration_ns,
p.interval_ns,
p.length,
p.received_stats,
p.stamp_at,
p.clock,
p.dscp,
p.server_fill,
]
}
fn assert_error(bytes: &[u8], expected: ProtoError) {
assert_eq!(Params::decode(bytes).unwrap_err(), expected, "{bytes:02x?}");
assert_eq!(
Params::decode_with_presence(bytes).unwrap_err(),
expected,
"{bytes:02x?}"
);
}
#[test]
fn known_encodings_preserve_raw_dscp_and_utf8_byte_lengths() {
for (params, bytes) in [
(
Params {
dscp: 46,
..Params::default()
},
vec![8, 92],
),
(
Params {
dscp: 184,
..Params::default()
},
vec![8, 0xf0, 2],
),
(
Params {
server_fill: Some(ServerFill {
value: "rand".into(),
}),
..Params::default()
},
vec![9, 4, b'r', b'a', b'n', b'd'],
),
(
Params {
server_fill: Some(ServerFill { value: "é".into() }),
..Params::default()
},
vec![9, 2, 0xc3, 0xa9],
),
] {
assert_eq!(params.encode(), bytes);
assert_eq!(Params::decode(&bytes).unwrap(), params);
}
}
#[test]
fn wire_defaults_and_explicit_zero_presence() {
let defaults = Params {
protocol_version: 0,
duration_ns: 0,
interval_ns: 0,
length: 0,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Unspecified,
dscp: 0,
server_fill: None,
};
let absent = Params::decode_with_presence(&[]).unwrap();
assert_eq!(absent.params, defaults);
assert_eq!(presence(absent.presence), [false; 9]);
assert_eq!(defaults.encode(), Vec::<u8>::new());
assert!(!defaults.clock.has_wall());
assert!(!defaults.clock.has_mono());
// Clock is the sole integer tag for which explicit zero is invalid.
for tag in [1, 2, 3, 4, 5, 6, 8] {
let bytes = int(tag, 0);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, defaults);
let mut expected = [false; 9];
expected[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected, "tag {tag}");
assert_eq!(Params::decode(&bytes).unwrap(), decoded.params);
}
let empty_fill = Params::decode_with_presence(&[9, 0]).unwrap();
assert_eq!(
empty_fill.params.server_fill,
Some(ServerFill {
value: String::new()
})
);
assert_eq!(
presence(empty_fill.presence),
[false, false, false, false, false, false, false, false, true]
);
assert_eq!(empty_fill.params.encode(), [9, 0]);
assert_eq!(
Params {
protocol_version: 1,
..defaults
}
.encode(),
[1, 2]
);
}
#[test]
fn known_tags_map_to_their_fields_and_report_isolated_presence() {
for (tag, value, expected) in [
(
1,
1,
Params {
protocol_version: 1,
..Params::default()
},
),
(
2,
5,
Params {
duration_ns: 5,
..Params::default()
},
),
(
3,
6,
Params {
interval_ns: 6,
..Params::default()
},
),
(
4,
7,
Params {
length: 7,
..Params::default()
},
),
(
8,
184,
Params {
dscp: 184,
..Params::default()
},
),
] {
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
assert_eq!(decoded.params, expected);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
for (tag, modes) in [
(
5,
vec![
ReceivedStats::None as i64,
ReceivedStats::Count as i64,
ReceivedStats::Window as i64,
ReceivedStats::Both as i64,
],
),
(
6,
vec![
StampAt::None as i64,
StampAt::Send as i64,
StampAt::Receive as i64,
StampAt::Both as i64,
StampAt::Midpoint as i64,
],
),
(
7,
vec![
Clock::Wall as i64,
Clock::Monotonic as i64,
Clock::Both as i64,
],
),
] {
for (index, mode) in modes.into_iter().enumerate() {
let value = index as i64 + if tag == 7 { 1 } else { 0 };
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
let actual = match tag {
5 => decoded.params.received_stats as i64,
6 => decoded.params.stamp_at as i64,
7 => decoded.params.clock as i64,
_ => unreachable!(),
};
assert_eq!(actual, mode);
let mut expected_presence = [false; 9];
expected_presence[tag as usize - 1] = true;
assert_eq!(presence(decoded.presence), expected_presence);
}
}
}
#[test]
fn malformed_values_and_invalid_enums_are_rejected_by_both_decoders() {
for (tag, value, name) in [
(5, 4, "ReceivedStats"),
(6, 5, "StampAt"),
(7, 0, "Clock"),
(7, 4, "Clock"),
(5, -1, "ReceivedStats"),
(6, -1, "StampAt"),
(7, -1, "Clock"),
] {
assert_error(&int(tag, value), ProtoError::InvalidEnum { name, value });
}
for bytes in [&[1, 0x80][..], &[99], &[0x80], &[9, 0x80]] {
assert_error(bytes, ProtoError::TruncatedVarint);
}
}
#[test]
fn server_fill_accepts_valid_bytes_and_enforces_length_and_utf8_boundaries() {
for value in [
"",
"rand",
"0123456789abcdef0123456789abcdef",
"éééééééééééééééé",
] {
let expected = Params {
server_fill: Some(ServerFill {
value: value.into(),
}),
..Params::default()
};
let bytes = fill(value.as_bytes());
assert_eq!(expected.encode(), bytes);
let decoded = Params::decode_with_presence(&bytes).unwrap();
assert_eq!(decoded.params, expected);
assert!(decoded.presence.server_fill);
assert_eq!(Params::decode(&bytes).unwrap(), expected);
}
assert_error(
&fill(b"0123456789abcdef0123456789abcdefx"),
ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 },
);
assert_error(&[9, 4, b'a', b'b'], ProtoError::MalformedParams);
assert_error(&[9, 1, 0xff], ProtoError::InvalidUtf8);
}
#[cfg(target_pointer_width = "32")]
#[test]
fn server_fill_length_too_large_for_usize_is_rejected() {
let mut bytes = vec![9];
let length = u64::from(u32::MAX) + 1;
varint::encode_uvarint(length, &mut bytes);
assert_error(
&bytes,
ProtoError::ParameterLengthTooLarge { tag: 9, length },
);
}
fn string_strategy() -> impl Strategy<Value = String> {
// Eight arbitrary Unicode scalars occupy at most the allowed 32 bytes.
proptest::collection::vec(any::<char>(), 0..=8).prop_map(|chars| chars.into_iter().collect())
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<[i64; 5]>(),
prop::sample::select(vec![
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
]),
prop::sample::select(vec![
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
]),
prop::sample::select(vec![
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
]),
prop::option::of(string_strategy()),
)
.prop_map(
|(scalar, received_stats, stamp_at, clock, server_fill)| Params {
protocol_version: scalar[0],
duration_ns: scalar[1],
interval_ns: scalar[2],
length: scalar[3],
dscp: scalar[4],
received_stats,
stamp_at,
clock,
server_fill: server_fill.map(|value| ServerFill { value }),
},
)
}
fn all_tags(params: &Params) -> Vec<u8> {
let mut out = Vec::new();
// Deliberately bypass encode's omission: duplicate zero values must be present.
for (tag, value) in [
(1, params.protocol_version),
(2, params.duration_ns),
(3, params.interval_ns),
(4, params.length),
(5, params.received_stats as i64),
(6, params.stamp_at as i64),
(7, params.clock as i64),
(8, params.dscp),
] {
out.extend_from_slice(&int(tag, value));
}
out.extend_from_slice(&fill(params.server_fill.as_ref().unwrap().value.as_bytes()));
out
}
proptest! {
#[test]
fn valid_params_round_trip_with_omission_presence(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(Params::decode(&encoded).unwrap(), params.clone());
prop_assert_eq!(presence(decoded.presence), [
params.protocol_version != 0, params.duration_ns != 0,
params.interval_ns != 0, params.length != 0,
params.received_stats != ReceivedStats::None, params.stamp_at != StampAt::None,
params.clock != Clock::Unspecified, params.dscp != 0, params.server_fill.is_some(),
]);
}
#[test]
fn repeated_known_tags_are_last_wins(
mut first in params_strategy(), mut second in params_strategy(),
first_clock in 1_i64..=3, second_clock in 1_i64..=3,
first_fill in string_strategy(), second_fill in string_strategy(),
) {
first.clock = Clock::try_from(first_clock).unwrap();
second.clock = Clock::try_from(second_clock).unwrap();
first.server_fill = Some(ServerFill { value: first_fill });
second.server_fill = Some(ServerFill { value: second_fill });
let mut bytes = all_tags(&first);
bytes.extend_from_slice(&all_tags(&second));
let decoded = Params::decode_with_presence(&bytes).unwrap();
prop_assert_eq!(&decoded.params, &second);
prop_assert_eq!(Params::decode(&bytes).unwrap(), second);
prop_assert_eq!(presence(decoded.presence), [true; 9]);
}
#[test]
fn unknown_scalar_tags_do_not_disturb_known_params(
params in params_strategy(), tag in 10_u64..=u16::MAX as u64, value: u64,
) {
let encoded = params.encode();
let expected = Params::decode_with_presence(&encoded).unwrap();
let mut unknown = Vec::new();
varint::encode_uvarint(tag, &mut unknown);
varint::encode_uvarint(value, &mut unknown);
for bytes in [[unknown.as_slice(), &encoded].concat(), [&encoded, unknown.as_slice()].concat()] {
prop_assert_eq!(Params::decode_with_presence(&bytes).unwrap(), expected.clone());
prop_assert_eq!(Params::decode(&bytes).unwrap(), params.clone());
}
}
}
@@ -54,33 +54,41 @@ fn echo_reply(params: &Params, flags: u8) -> EchoReply {
}
#[test]
fn authenticated_reply_encoders_set_hmac_from_key() {
fn reply_encoders_normalize_hmac_flags_from_the_key() {
let params = params();
let open_reply = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_open_reply(&packet, Some(KEY)),
Ok(OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC,
..open_reply
})
);
let echo_reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&echo_reply, &params, Some(KEY)).unwrap();
assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC);
assert_eq!(
decode_echo_reply(&packet, &params, Some(KEY)),
Ok(EchoReply {
flags: FLAG_REPLY | FLAG_HMAC,
..echo_reply
})
);
for key in [None, Some(KEY)] {
for supplied_hmac in [0, FLAG_HMAC] {
let auth = if key.is_some() { FLAG_HMAC } else { 0 };
let open = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | supplied_hmac,
token: TOKEN,
params: params.clone(),
};
let packet = encode_open_reply(&open, key).unwrap();
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | auth);
let offset = if key.is_some() { 20 } else { 4 };
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(
decode_open_reply(&packet, key).unwrap(),
OpenReply {
flags: packet[3],
..open
}
);
let echo = echo_reply(&params, FLAG_REPLY | supplied_hmac);
let packet = encode_echo_reply(&echo, &params, key).unwrap();
assert_eq!(packet[3], FLAG_REPLY | auth);
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
assert_eq!(&packet[offset + 8..offset + 12], &u32::MAX.to_le_bytes());
assert_eq!(
decode_echo_reply(&packet, &params, key).unwrap(),
EchoReply {
flags: packet[3],
..echo
}
);
}
}
}
#[test]
@@ -95,6 +103,12 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
};
let packet = encode_open_reply(&rejected, None).unwrap();
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
let mut zero_without_close = packet.clone();
zero_without_close[3] &= !FLAG_CLOSE;
assert_eq!(
decode_open_reply(&zero_without_close, None),
Err(ProtoError::ZeroToken)
);
assert_eq!(
encode_open_reply(
@@ -119,63 +133,51 @@ fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() {
}
#[test]
fn reply_encoders_supply_their_packet_type_rules() {
fn reply_codecs_enforce_packet_type_and_reserved_flag_rules() {
let params = Params::default();
assert_eq!(
encode_open_reply(
&OpenReply {
flags: FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
None,
for (flags, expected) in [
(FLAG_REPLY, ProtoError::MissingFlag(FLAG_OPEN)),
(FLAG_OPEN, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY | 0x10,
ProtoError::ReservedFlags(0x10),
),
Err(ProtoError::MissingFlag(FLAG_OPEN))
);
assert_eq!(
encode_echo_reply(
&EchoReply {
flags: 0,
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::MissingFlag(FLAG_REPLY))
);
}
/// Every packet the reply encoders produce must be rejected by the inbound
/// request decoder: a server never answers a datagram carrying `FLAG_REPLY`.
#[test]
fn encoded_replies_are_never_admitted_as_inbound_requests() {
let params = Params::default();
for reply_flags in [FLAG_OPEN | FLAG_REPLY, FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_open_reply(
] {
let open = OpenReply {
flags,
token: TOKEN,
params: params.clone(),
};
assert_eq!(encode_open_reply(&open, None).unwrap_err(), expected);
let mut packet = encode_open_reply(
&OpenReply {
flags: reply_flags,
token: if reply_flags & FLAG_CLOSE == 0 {
TOKEN
} else {
0
},
params: params.clone(),
flags: FLAG_OPEN | FLAG_REPLY,
..open
},
None,
)
.unwrap();
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
);
packet[3] = flags;
assert_eq!(decode_open_reply(&packet, None).unwrap_err(), expected);
}
for reply_flags in [FLAG_REPLY, FLAG_REPLY | FLAG_CLOSE] {
let packet = encode_echo_reply(&echo_reply(&params, reply_flags), &params, None).unwrap();
for (flags, expected) in [
(0, ProtoError::MissingFlag(FLAG_REPLY)),
(
FLAG_OPEN | FLAG_REPLY,
ProtoError::UnexpectedFlag(FLAG_OPEN),
),
(FLAG_REPLY | 0x10, ProtoError::ReservedFlags(0x10)),
] {
assert_eq!(
decode_request(&packet),
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
encode_echo_reply(&echo_reply(&params, flags), &params, None).unwrap_err(),
expected
);
let mut packet =
encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
packet[3] = flags;
assert_eq!(
decode_echo_reply(&packet, &params, None).unwrap_err(),
expected
);
}
}
@@ -207,7 +209,12 @@ fn echo_layout_combinations_round_trip() {
StampAt::Both,
StampAt::Midpoint,
] {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
for clock in [
Clock::Unspecified,
Clock::Wall,
Clock::Monotonic,
Clock::Both,
] {
for key in [None, Some(KEY)] {
let params = Params {
protocol_version: 1,
@@ -265,44 +272,48 @@ fn echo_reply_requires_exact_negotiated_optional_fields() {
#[test]
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
let params = Params {
length: 20,
..Params::default()
};
let packet = encode_request(echo_request(&params, &[1, 2]), None).unwrap();
assert_eq!(&packet[16..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), None),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
};
let packet = encode_echo_reply(&reply, &params, None).unwrap();
assert_eq!(&packet[16..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, None).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, None).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
for stats in [ReceivedStats::None, ReceivedStats::Both] {
for key in [None, Some(KEY)] {
let mut params = Params {
received_stats: stats,
..Params::default()
};
let offset = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (offset + 4) as i64;
let packet = encode_request(echo_request(&params, &[1, 2]), key).unwrap();
assert_eq!(&packet[offset..], &[1, 2, 0, 0]);
assert_eq!(
encode_request(echo_request(&params, &[0; 5]), key),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
let reply = EchoReply {
payload: vec![3, 4],
..echo_reply(&params, FLAG_REPLY)
},
&params,
None,
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5,
})
);
};
let packet = encode_echo_reply(&reply, &params, key).unwrap();
assert_eq!(&packet[offset..], &[3, 4, 0, 0]);
let decoded = decode_echo_reply(&packet, &params, key).unwrap();
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
assert_eq!(encode_echo_reply(&decoded, &params, key).unwrap(), packet);
assert_eq!(
encode_echo_reply(
&EchoReply {
payload: vec![0; 5],
..reply
},
&params,
key
),
Err(ProtoError::PayloadTooLarge {
available: 4,
provided: 5
})
);
}
}
}
#[test]
@@ -321,6 +332,14 @@ fn a_negative_negotiated_length_still_encodes_in_both_directions() {
let request = encode_request(echo_request(&params, &[]), None)
.expect("a negative negotiated length must still encode a request");
assert_eq!(request.len(), 16);
assert_eq!(
decode_request(&request).unwrap().kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &[]
}
);
let reply = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None)
.expect("a negative negotiated length must still encode a reply");
@@ -505,12 +524,14 @@ fn midpoint_dual_field_reply_authenticates_over_full_packet() {
let reply = decode_echo_reply(&packet, &params, Some(KEY)).unwrap();
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
let mut corrupted = packet;
corrupted[HMAC_OFFSET] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
for offset in [HMAC_OFFSET, PacketLayout::echo(true, &params).header_len()] {
let mut corrupted = packet.clone();
corrupted[offset] ^= 0xFF;
assert_eq!(
decode_echo_reply(&corrupted, &params, Some(KEY)),
Err(ProtoError::BadHmac)
);
}
}
#[test]
@@ -539,61 +560,92 @@ fn only_the_exact_compat_length_is_accepted_as_an_extension() {
}
#[test]
fn midpoint_both_clock_reply_has_no_additional_extension() {
let params = midpoint_params(Clock::Both, 0);
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
fn other_layouts_have_no_longer_form_exception() {
for (stamp_at, clock) in [
(StampAt::Midpoint, Clock::Both),
(StampAt::Receive, Clock::Wall),
] {
let params = Params {
stamp_at,
clock,
..Params::default()
};
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len()
})
);
}
}
#[test]
fn non_midpoint_stamp_at_has_no_longer_form_exception() {
let params = Params {
received_stats: ReceivedStats::None,
stamp_at: StampAt::Receive,
clock: Clock::Wall,
..Params::default()
};
let reply = echo_reply(&params, FLAG_REPLY);
let packet = encode_echo_reply(&reply, &params, None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
assert_eq!(
decode_echo_reply(&too_long, &params, None),
Err(ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual: too_long.len(),
})
);
}
/// The midpoint compatibility length is a *reply* concept. An inbound ECHO
/// request of that length is just a longer request, which a receiver accepts
/// without applying any negotiated length rule.
#[test]
fn echo_request_decoding_has_no_compat_length_rule() {
let params = midpoint_params(Clock::Wall, 0);
let packet = encode_request(echo_request(&params, &[]), None).unwrap();
let mut too_long = packet.clone();
too_long.extend_from_slice(&[0; 8]);
let request = decode_request(&too_long).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: 17,
tail: &too_long[16..],
fn reply_decoders_enforce_hmac_presence_and_authenticate_the_body() {
let params = params();
for key in [None, Some(KEY)] {
let open = encode_open_reply(
&OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
key,
)
.unwrap();
let echo = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
let mismatched_key = if key.is_some() { None } else { Some(KEY) };
assert_eq!(
decode_open_reply(&open, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
assert_eq!(
decode_echo_reply(&echo, &params, mismatched_key),
Err(ProtoError::HmacPresenceMismatch)
);
if let Some(key) = key {
let mut bad_open = open;
let mut bad_echo = echo;
*bad_open.last_mut().unwrap() ^= 1;
*bad_echo.last_mut().unwrap() ^= 1;
assert_eq!(
decode_open_reply(&bad_open, Some(key)),
Err(ProtoError::BadHmac)
);
assert_eq!(
decode_echo_reply(&bad_echo, &params, Some(key)),
Err(ProtoError::BadHmac)
);
}
);
}
}
#[test]
fn echo_reply_length_errors_precede_hmac_verification() {
for key in [None, Some(KEY)] {
let mut params = Params::default();
let header = PacketLayout::echo(key.is_some(), &params).header_len();
params.length = (header + 4) as i64;
let packet = encode_echo_reply(&echo_reply(&params, FLAG_REPLY), &params, key).unwrap();
for actual in [header - 1, header, packet.len() - 1, packet.len() + 1] {
let mut malformed = packet.clone();
malformed.resize(actual, 0);
// Truncating or extending the signed packet also invalidates its MAC.
let expected = if actual < header {
ProtoError::PacketTooShort {
needed: header,
actual,
}
} else {
ProtoError::PacketLengthMismatch {
expected: packet.len(),
actual,
}
};
assert_eq!(decode_echo_reply(&malformed, &params, key), Err(expected));
}
}
}
+44 -171
View File
@@ -47,8 +47,6 @@ fn token_and_sequence() -> Vec<u8> {
body
}
// ---------- A. Classification ----------
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
match kind {
DecodedRequestKind::Open { no_test: false, .. } => "open",
@@ -71,6 +69,34 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
];
for no_test in [false, true] {
for key in [None, Some(KEY)] {
let params = Params::default();
let packet = encode_request(
RequestToEncode::Open {
params: &params,
no_test,
},
key,
)
.unwrap();
let expected_flags = FLAG_OPEN
| if no_test { FLAG_CLOSE } else { 0 }
| if key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(packet[3], expected_flags);
assert_eq!(
decode_request(&packet).unwrap().kind,
DecodedRequestKind::Open {
no_test,
params: &[]
}
);
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
for (flags, hmac_present, expected) in cases {
// Long enough for any kind, so only the flags decide classification.
let packet = raw(flags, &token_and_sequence(), 64);
@@ -82,6 +108,10 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
request.hmac_present, hmac_present,
"flags 0x{flags:02x} HMAC presence"
);
if let DecodedRequestKind::Open { params, .. } = request.kind {
let offset = if hmac_present { 20 } else { 4 };
assert_eq!(params, &packet[offset..]);
}
assert_eq!(
classify(&request.kind),
expected,
@@ -90,8 +120,6 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
}
}
// ---------- B. Invalid header / flags ----------
#[test]
fn structurally_invalid_headers_are_rejected() {
assert_eq!(
@@ -135,8 +163,6 @@ fn any_reply_flagged_datagram_is_rejected_as_a_request() {
}
}
// ---------- C. Structural minimum lengths ----------
#[test]
fn structural_minimum_lengths_are_enforced_per_kind() {
// (flags, minimum accepted length)
@@ -172,8 +198,6 @@ fn structural_minimum_lengths_are_enforced_per_kind() {
}
}
// ---------- D. Field offsets ----------
#[test]
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
for hmac in [false, true] {
@@ -211,69 +235,25 @@ fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
}
}
// ---------- E. Open parameter slice ----------
#[test]
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
let encoded = params().encode();
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let expected_offset = if hmac { 4 + HMAC_SIZE } else { 4 };
let packet = raw(flags, &encoded, expected_offset + encoded.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[expected_offset..]);
assert_eq!(Params::decode(params).unwrap(), self::params());
// Structural parsing accepts even malformed Params; semantic errors belong
// to Params::decode and are covered in params.rs.
for body in [params().encode(), Vec::new(), vec![1, 0x80]] {
for hmac in [false, true] {
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
let offset = if hmac { 20 } else { 4 };
let packet = raw(flags, &body, offset + body.len());
match decode_request(&packet).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &packet[offset..]);
}
other => panic!("expected an open request, got {other:?}"),
}
other => panic!("expected an open request, got {other:?}"),
}
}
}
#[test]
fn empty_and_malformed_open_params_decode_structurally() {
let empty = header(FLAG_OPEN);
match decode_request(&empty).unwrap().kind {
DecodedRequestKind::Open { params, .. } => assert!(params.is_empty()),
other => panic!("expected an open request, got {other:?}"),
}
// A truncated varint: structurally fine, semantically not. A receiver must
// be able to authenticate before spending effort on this.
let malformed = raw(FLAG_OPEN, &[1, 0x80], 6);
match decode_request(&malformed).unwrap().kind {
DecodedRequestKind::Open { params, .. } => {
assert_eq!(params, &[1, 0x80]);
assert_eq!(Params::decode(params), Err(ProtoError::TruncatedVarint));
}
other => panic!("expected an open request, got {other:?}"),
}
}
// ---------- F. Open precedence ----------
#[test]
fn open_precedence_beats_close_and_an_echo_shaped_body() {
// An OPEN|CLOSE datagram whose body looks exactly like a token and
// sequence number is still a no-test open, and those bytes are parameters.
let packet = raw(FLAG_OPEN | FLAG_CLOSE, &token_and_sequence(), 16);
assert_eq!(
decode_request(&packet).unwrap(),
DecodedRequest {
hmac_present: false,
kind: DecodedRequestKind::Open {
no_test: true,
params: &packet[4..],
},
}
);
}
// ---------- G. Trailing data ----------
#[test]
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
@@ -303,8 +283,6 @@ fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
}
}
// ---------- H. Zero token ----------
#[test]
fn a_zero_token_is_structurally_valid() {
assert_eq!(
@@ -323,8 +301,6 @@ fn a_zero_token_is_structurally_valid() {
);
}
// ---------- HMAC presence is not authentication ----------
#[test]
fn hmac_presence_is_reported_independently_of_mac_validity() {
// A structurally complete authenticated echo whose MAC is garbage.
@@ -378,106 +354,3 @@ fn packet_verification_requires_a_present_and_complete_hmac_field() {
Err(ProtoError::BadMagic)
);
}
// ---------- Encode -> decode cross-checks ----------
#[test]
fn encoded_requests_decode_back_to_their_sender_side_identity() {
for key in [None, Some(KEY)] {
for no_test in [false, true] {
let packet = encode_request(
RequestToEncode::Open {
params: &params(),
no_test,
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Open {
no_test: decoded,
params: encoded,
} => {
assert_eq!(decoded, no_test);
assert_eq!(Params::decode(encoded).unwrap(), params());
}
other => panic!("expected an open request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
let packet = encode_request(RequestToEncode::Close { token: TOKEN }, key).unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
assert_eq!(request.kind, DecodedRequestKind::Close { token: TOKEN });
// The ECHO tail is deliberately not compared against the logical
// payload: the sender placed it at a negotiated offset the receiver
// cannot recover without `Params`.
let echo_params = Params {
length: 96,
..params()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &echo_params,
payload: &[1, 2, 3, 4],
},
key,
)
.unwrap();
let request = decode_request(&packet).unwrap();
assert_eq!(request.hmac_present, key.is_some());
match request.kind {
DecodedRequestKind::Echo {
token, sequence, ..
} => {
assert_eq!(token, TOKEN);
assert_eq!(sequence, SEQUENCE);
}
other => panic!("expected an echo request, got {other:?}"),
}
if let Some(key) = key {
verify_packet_hmac(key, &packet).unwrap();
}
}
}
#[test]
fn echo_encoding_floors_a_negative_negotiated_length_at_the_field_block() {
// A negative negotiated length is accepted during open and echoed back
// unchanged, so a real session can carry one. It must therefore stay
// encodable: it asks for no space beyond the mandatory fields.
let params = Params {
length: -1,
..Params::default()
};
let packet = encode_request(
RequestToEncode::Echo {
token: TOKEN,
sequence: SEQUENCE,
params: &params,
payload: &[],
},
None,
)
.expect("a negative negotiated length must still encode");
// header (4) + token (8) + sequence (4).
assert_eq!(packet.len(), 16);
let request = decode_request(&packet).unwrap();
assert_eq!(
request.kind,
DecodedRequestKind::Echo {
token: TOKEN,
sequence: SEQUENCE,
tail: &[],
}
);
}
+108
View File
@@ -0,0 +1,108 @@
use irtt_proto::{varint::*, ProtoError};
use proptest::prelude::*;
#[test]
fn boundary_values_round_trip() {
for value in [
0,
1,
2,
127,
128,
255,
16_384,
u32::MAX as u64,
i64::MAX as u64,
u64::MAX,
] {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
for value in [
i64::MIN,
-9_223_372_036,
-2,
-1,
0,
1,
2,
1_000_000_000,
3_000_000_000,
i64::MAX,
] {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
}
#[test]
fn verified_byte_examples() {
let cases: &[(i64, &[u8])] = &[
(1, &[0x02]),
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
(1472, &[0x80, 0x17]),
(3, &[0x06]),
(184, &[0xf0, 0x02]),
];
for (value, expected) in cases {
let mut encoded = Vec::new();
encode_varint(*value, &mut encoded);
assert_eq!(&encoded, expected);
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
}
for tag in [9, 24] {
let mut encoded = Vec::new();
encode_uvarint(tag, &mut encoded);
assert_eq!(encoded, [tag as u8]);
}
}
#[test]
fn truncated_and_overflowing_varints_are_rejected() {
// Incomplete input differs from a tenth byte whose bits cannot fit u64,
// or an eleventh byte after ten continuation bytes.
for (bytes, expected) in [
(vec![], ProtoError::TruncatedVarint),
(vec![0x80], ProtoError::TruncatedVarint),
(vec![0x80; 10], ProtoError::TruncatedVarint),
(
[vec![0x80; 9], vec![2]].concat(),
ProtoError::VarintOverflow,
),
(
[vec![0x80; 10], vec![0]].concat(),
ProtoError::VarintOverflow,
),
] {
assert_eq!(decode_uvarint(&bytes), Err(expected), "{bytes:02x?}");
assert_eq!(
decode_varint(&bytes),
decode_uvarint(&bytes).map(|(v, n)| (zigzag_decode(v), n))
);
}
}
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_uvarint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
prop_assert_eq!(decode_varint(&encoded).unwrap(), (value, encoded.len()));
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ irtt-proto.workspace = true
getrandom = "0.4.3"
socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.21"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
# Wildcard listeners recover each request's local destination address and send
# the reply from it. Only these targets have a safe destination-metadata and
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-stats"
version = "0.5.1"
version = "0.5.2"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
+32
View File
@@ -38,6 +38,38 @@ are replayed in arrival order; backdated events cannot move the window backwards
Reading a snapshot does not advance time. Use count-based rolling storage for a
hard bound on retained events. The memory estimate excludes time-window storage.
Rolling directional loss uses the increase in the highest observed server
receive count within one observation segment across the retained packet events
in arrival order. Upstream loss is window sends minus that increase; downstream
loss is the increase minus all window replies, including duplicates and untracked
late replies, as in cumulative accounting. The baseline must be supplied by the
packet event immediately before
the first retained packet event, or is zero if no earlier packet events existed.
It must also equal the highest count observed in that segment. Intervening sends
or replies without a current count make an older baseline stale: its next increase
may cover packets outside the window. Each retained event keeps the count's observation
ordinal, so eviction cannot erase this distinction. Reordered lower counts do not
refresh the baseline; only matched unique replies supply counter observations,
regardless of the late-reply timing policy.
Both directional packet estimates are `None` if the baseline or endpoint is
unknown or stale, or if time filtering excludes a packet event between retained
packet events. Their percentage fields use the existing unavailable value of `0.0`.
Loss/warning events do not affect this interval. Signed estimates remain possible
when server counts exceed local expectations or extra replies arrive, and
outstanding sends retain the existing provisional-loss interpretation. These are
changes in observed accounting, not exact network transit loss for probes sent
in a wall-clock period.
Raw `server_packets_received`, ordinary packet counts, and cumulative loss retain
their existing meanings.
A counter jump of at least half the 32-bit range is ambiguous between counter
wrap and a very old reordered reply from the other side of a wrap. It starts a
new observation segment; windows crossing the discontinuity have no directional
estimate. Later windows can recover using a fresh baseline within that segment.
The collector does not infer an epoch from incomplete arrival history. Smaller
regressions retain the existing maximum-count interpretation for reordered replies.
Exact snapshots sort a temporary copy of each metric's retained samples, adding
O(n) scratch memory and O(n log n) computation for that metric. The retention
estimate is not peak snapshot or allocator accounting.
+1 -1
View File
@@ -234,7 +234,7 @@ impl CoreStats {
Snapshot {
events: self.events,
packets,
loss: loss_stats(packets),
loss: loss_stats(packets, packets.server_packets_received),
send_call: self.send_call.snapshot(),
timer_error: self.timer_error.snapshot(),
rtt: self.rtt_stats(),
+3 -2
View File
@@ -599,8 +599,9 @@ pub struct PacketCounts {
pub bytes_received: u64,
/// Highest cumulative server-reported packets received, when available.
///
/// This is the only server-reported input to the directional loss estimates
/// in [`LossStats`].
/// Rolling snapshots also preserve this raw cumulative value from retained
/// replies. Their directional loss uses an interval-relative increase
/// instead; see [`LossStats`] for baseline and availability semantics.
pub server_packets_received: Option<u64>,
/// Raw server-reported receive window from the most recently processed
/// reply that carried one, when available.
+16 -5
View File
@@ -8,9 +8,20 @@ use crate::PacketCounts;
/// server-reported counts exceed local expectations.
///
/// The directional fields are derived from
/// [`PacketCounts::server_packets_received`] and are `None` when the server
/// reported no receive count. [`PacketCounts::server_received_window`] is never
/// used to derive loss.
/// [`PacketCounts::server_packets_received`] for cumulative snapshots. Rolling
/// snapshots use the increase in the highest observed count over the retained
/// packet-event arrival interval within one observation segment. The baseline
/// must be that segment's highest count, reported by the preceding packet event
/// (zero at the start of the collector's history). They are `None` without a
/// current baseline or known endpoint, or when time filtering leaves an interior
/// packet gap. A jump of at least half the
/// 32-bit counter range starts a new observation segment: wrap and old replies
/// are ambiguous. Windows crossing that discontinuity have no directional loss;
/// later windows can use a fresh baseline within the new segment.
/// The percentage fields are `0.0` when the corresponding estimate is unavailable.
/// These remain estimates: outstanding sends, excess server counts, or extra
/// replies can produce signed values. [`PacketCounts::server_received_window`]
/// is never used to derive loss.
pub struct LossStats {
/// Locally inferred total lost packets.
pub lost_packets: u64,
@@ -30,7 +41,7 @@ pub struct LossStats {
pub late_packets_percent: f64,
}
pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
pub(crate) fn loss_stats(packets: PacketCounts, server_received: Option<u64>) -> LossStats {
let lost = packets.packets_sent.saturating_sub(packets.unique_replies);
let packet_loss_percent = if packets.packets_sent == 0 {
0.0
@@ -45,7 +56,7 @@ pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
upstream_loss_percent,
downstream_loss_packets,
downstream_loss_percent,
) = if let Some(server_received) = packets.server_packets_received {
) = if let Some(server_received) = server_received {
let upstream = i128::from(packets.packets_sent) - i128::from(server_received);
let downstream = i128::from(server_received) - i128::from(packets.packets_received);
let upstream_percent = if packets.packets_sent == 0 {
+2 -2
View File
@@ -124,8 +124,8 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
at: (*timeout_at).max(sent_at.mono),
}),
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
ClientEvent::SessionStarted { .. }
| ClientEvent::NoTestCompleted { .. }
ClientEvent::SessionStarted(_)
| ClientEvent::NoTestCompleted(_)
| ClientEvent::SessionClosed { .. } => None,
}
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more