Compare commits

...
149 Commits
Author SHA1 Message Date
Nils Andreas Svee 8f39ebe8aa ci: downgrade runner to ubuntu-22.04 and enable full features
Revert the GitHub Actions runner to ubuntu-22.04 for better compatibility and ensure the "full" feature set is passed to cargo-dist during the build process.
2026-07-05 00:33:11 +07:00
Nils Andreas Svee 68c7f92cb4 chore: bump version to 0.4.0 across all workspace packages
Update the version of irtt-proto, irtt-client, irtt-stats, and irtt-cli to 0.4.0 in preparation for a new release.
2026-07-05 00:30:19 +07:00
Nils Andreas Svee d2ae68a05f feat(tui): refactor graph metrics and simplify view navigation
Consolidate graph modes into a single GraphMetric enum to support more granular metrics like one-way delays and server processing. Replace the "full graph" toggle with a dedicated TuiView system to improve UI state management and status line clarity.
2026-07-05 00:28:22 +07:00
Nils Andreas Svee f9c6728855 feat(tui): add time-based graph navigation and zooming
Implement a scrollable and zoomable viewport for the TUI graphs, allowing users to pan through historical data using arrow keys and adjust the time window with zoom controls. This transitions graph rendering from sample-count based to timestamp-based for more accurate temporal visualization.
2026-07-04 23:44:18 +07:00
Nils Andreas Svee b9f5367ed1 feat(tui): add manual Y-axis scaling and improve graph axis logic
Introduce a toggleable graph scale (Auto, 100ms–2s) via the 's' key to provide better control over visualization ranges and implement smarter Y-axis bounds that handle signed versus non-negative metrics.
2026-07-04 22:54:47 +07:00
Nils Andreas Svee c483a12173 chore(deps): update time crate to 0.3.53
Update the time dependency in Cargo.lock to the latest patch version to maintain the lockfile and incorporate upstream fixes.
2026-07-03 00:58:05 +07:00
Nils Andreas Svee c5ca3d5a93 test(cli): add integration tests for disabled client feature
Verify that the CLI correctly reports the unavailability of the client applet and provides instructions on how to enable it via features.
2026-07-03 00:57:37 +07:00
Nils Andreas Svee baa2a862f5 feat(tui): expand multi-target graph metrics and enhance visualization
Enable switching between different RTT metrics in multi-target mode and add color-coded labels to the target table for better correlation with graph series.
2026-07-03 00:53:57 +07:00
renovate[bot] e987d31f2f chore(deps): update github actions 2026-07-03 00:50:28 +07:00
renovate[bot] 6602b7496a chore(deps): maintain cargo lockfile 2026-06-28 17:30:52 +00:00
Nils Andreas Svee a62c53a77e feat(tui): improve multi-target lifecycle management and UI clarity
Refine the TUI event loop to explicitly handle different exit conditions and ensure sessions stop correctly on idle timeouts or interruptions. Update UI labels to clarify when metrics or headers refer specifically to the first target in multi-target sessions.
2026-06-23 23:41:35 +07:00
Nils Andreas Svee 836a8abbeb feat(tui): add multi-target support and improved dashboard visualization
Enable simultaneous probing of multiple targets within the TUI using a new target status table and multi-series RTT graph. Refactor target resolution logic into a shared module to support consistent behavior across CLI and TUI components.
2026-06-23 22:36:49 +07:00
Nils Andreas Svee 2065fe5e11 test(cli): add comprehensive tests for multi-target resolution and output
Add unit tests for target normalization and address family filtering, and expand integration tests to verify column rendering and CSV output for multiple targets. Update subscriber config to drop oldest events under backpressure for better CLI stability.
2026-06-23 21:38:23 +07:00
Nils Andreas Svee 69fd24ce64 feat(cli): add multi-target probing support and labels
Enable concurrent probing of multiple targets using positional arguments or the `--target` flag with unique labels. Update output formats to include a `target` column for logical attribution in multi-target runs.
2026-06-23 21:26:46 +07:00
Nils Andreas Svee a8622ee15a build: allow Zlib license in cargo-deny configuration
Add Zlib to the list of accepted licenses to support new dependencies or updates that utilize this license.
2026-06-23 21:20:39 +07:00
Nils Andreas Svee 5dce8d55ee build: update time crate from 0.3.50 to 0.3.51
Update the time dependency in Cargo.lock to the latest patch version.
2026-06-23 21:14:28 +07:00
Nils Andreas Svee 543f0e57c8 fix(client): prevent premature task rescheduling in staggered pacing
Ensure targets are only scheduled when they are actually due to prevent unchanged targets from resending too early when the active set changes.
2026-06-23 21:12:01 +07:00
Nils Andreas Svee acffc3cd4e feat(client): implement multi-target ManagedClientGroup for shared-socket probing
Introduce a managed group runner to support concurrent probing of multiple targets over a single UDP socket with coordinated send pacing. Refactor the existing client logic into a reusable SessionRuntime to support both individual and grouped session management.
2026-06-23 21:02:54 +07:00
Nils Andreas Svee ac82537043 docs: clarify applet availability and server implementation status
Update README to specify that irtt-tui requires the tui feature and explicitly state that server support is not yet implemented.
2026-06-22 17:03:56 +07:00
Nils Andreas Svee 170f229e7b refactor: update build documentation and switch comparison script to CSV
Update README with comprehensive build/verification instructions and modify the client comparison script to consume CSV output instead of the legacy machine format.
2026-06-22 16:01:25 +07:00
Nils Andreas Svee 5b40593fda refactor(tui): normalize late reply processing and display warning counts
Treat late replies as diagnostic events without RTT metadata to ensure consistent TUI statistics and add a visibility toggle for warning events in the packet panel.
2026-06-22 15:22:41 +07:00
Nils Andreas Svee b33f01cfe1 feat(cli): enhance help documentation and dynamic applet dispatch 2026-06-22 15:13:36 +07:00
Nils Andreas Svee f1d442d798 refactor(cli): separate binary entry points from library logic
Move the main execution logic to a dedicated internal module and create distinct entry files for each binary to improve project structure and build configuration.
2026-06-22 13:41:28 +07:00
Nils Andreas Svee d05d6a941b chore(deps): maintain cargo lockfile 2026-06-22 13:31:14 +07:00
Nils Andreas Svee 9c63a03454 feat(cli): refine default table output and introduce "default" column alias
Simplify the default table view by moving verbose columns to the verbose mode and allow users to explicitly request the default layout using `--columns default`.
2026-06-22 13:25:16 +07:00
Nils Andreas Svee b02bc53cd8 feat(cli): introduce flexible event row output formats and column selection
Replace fixed output modes with customizable table, CSV, TSV, and JSON Lines formats. This allows users to select specific data columns and control header visibility for better integration with scripts and monitoring tools.
2026-06-22 13:16:33 +07:00
renovate[bot] 4b408d3ab4 chore(deps): update github actions to v0.5.7 2026-06-22 12:20:10 +07:00
renovate[bot] f9f7d35793 chore(deps): maintain cargo lockfile 2026-06-22 12:20:07 +07:00
renovate[bot] 62e0f0cf9f chore(deps): update github actions 2026-06-21 18:24:05 +00:00
Nils Andreas Svee 193487ae64 refactor(cli): align machine output wall clock field and refine visibility
Update the machine-readable wall clock field to "event_wall_ns" for consistency and restrict visibility of internal rendering types. The extensive test suite was removed as part of this cleanup.
2026-06-21 00:19:38 +07:00
Nils Andreas Svee cfbac69ac6 build: update lockfile to version 4 and deduplicate dependencies
Upgrade Cargo.lock to version 4 and remove redundant versions of crossterm, rustix, and windows-sys to reduce binary size and build complexity.
2026-06-21 00:12:23 +07:00
Nils Andreas Svee f72f8fda5a refactor(cli): harden client output formatter 2026-06-21 00:10:56 +07:00
Nils Andreas Svee c7d03c0358 test(client): strengthen assertions for session cleanup after peer closure
Update tests to ensure no packets at all are sent following a peer-initiated closure, rather than just checking for the absence of the close flag.
2026-06-20 23:08:00 +07:00
Nils Andreas Svee 97dc1b3f8d test(client): add tests for peer-initiated session closure 2026-06-20 22:59:48 +07:00
Nils Andreas Svee e33ae1d0fc fix(client): handle peer-initiated session closure gracefully 2026-06-20 22:59:48 +07:00
Nils Andreas Svee d4976fb811 refactor(client): track session closure source 2026-06-20 22:59:48 +07:00
Nils Andreas Svee 74ceef6a70 test(cli): add unit and integration tests for applet dispatching 2026-06-20 22:59:48 +07:00
Nils Andreas Svee fe776e6672 feat(cli): add multi-applet dispatcher with subcommand support 2026-06-20 22:59:48 +07:00
Nils Andreas Svee efaa60cbd4 refactor(cli): simplify applet detection and remove feature-based defaults 2026-06-20 22:59:48 +07:00
Nils Andreas Svee dc65daec75 build: bump minimum supported rust version to 1.85
Update the rust-version in Cargo.toml and the installation requirements in README.md to reflect the new MSRV.
2026-06-20 22:35:07 +07:00
Nils Andreas Svee e3b74441a7 fix(deps): update rust crate ratatui to 0.30.2 2026-06-20 22:32:43 +07:00
renovate[bot] 5595d6e78e fix(deps): update rust crate crossterm to 0.29.0 2026-06-20 22:31:44 +07:00
renovate[bot] fe1f1a9c5b chore(deps): pin dependencies 2026-06-14 17:53:37 +00:00
Nils Andreas Svee afe07f0b9a refactor(cli): reorganize feature flags and update distribution config 2026-06-10 15:19:34 +07:00
Nils Andreas Svee 352cebb5c3 build: enable tui feature in distribution workspace
Include the TUI module in release builds to ensure command-line interface functionality is available in distributed binaries.
2026-06-10 00:59:40 +07:00
Nils Andreas Svee 58fd017d4f build(ci): update cargo-dist to v0.32.0 and revert runner to ubuntu-22.04
Upgrade CI tooling to use cargo-dist v0.32.0 and switch to the ubuntu-22.04 runner to ensure environment compatibility. Pin GitHub Actions to major versions for better maintainability.
2026-06-10 00:47:34 +07:00
Nils Andreas Svee 5efb8a1870 build: bump workspace and crate versions to 0.3.0 2026-06-10 00:44:10 +07:00
Nils Andreas Svee f41fd4a8e7 refactor: simplify lib.rs and update feature gating for cmd modules 2026-06-10 00:41:03 +07:00
Nils Andreas Svee 5de1bf7343 docs: update README with multi-applet usage and installation instructions 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 7e89d11e6e build: configure multi-binary targets and feature-gate dependencies 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 196fc29f76 feat(tui): optimize terminal UI and simplify graph data series 2026-06-09 23:23:51 +07:00
Nils Andreas Svee ded8a5f87d refactor(client): encapsulate session lifecycle and stream output 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 133ff71b72 feat(cli): implement multi-applet binary dispatch and signal handling 2026-06-09 23:23:50 +07:00
Nils Andreas Svee d7cdcc5cee refactor(cli): reorganize project structure into modular commands 2026-06-09 23:23:41 +07:00
Nils Andreas Svee c31ad07d61 feat(irtt-cli): add optional tui output mode 2026-06-09 13:29:00 +07:00
renovate[bot] ece9fd6db0 chore(deps): maintain cargo lockfile 2026-06-09 11:32:29 +07:00
Nils Andreas Svee 9b0cc2b395 test(ci): split netem interop summaries into local and GitHub-specific reports
Create separate markdown files for local browsing with relative links and GitHub Action summaries with artifact notices. This ensures the GitHub Step Summary remains readable and informative without broken links.
2026-06-05 17:13:51 +07:00
renovate[bot] b544df7cc9 fix(deps): update socket dependencies to 0.6.4 2026-06-05 10:43:53 +07:00
renovate[bot] 5646365677 chore(deps): update github actions 2026-06-05 10:43:31 +07:00
Nils Andreas Svee 64be840c1e test(ci): enhance netem interop reporting and loss validation 2026-06-04 09:16:32 +07:00
Nils Andreas Svee b8b0425c4e test(ci): add deterministic Linux netem interop testing 2026-06-03 14:48:51 +07:00
Nils Andreas Svee 3e07732934 test(scripts): add interoperability harness to compare irtt clients
Add a Python-based black-box testing script to validate the Rust client's output and metrics against the upstream Go implementation.
2026-05-25 16:22:46 +07:00
renovate[bot] 61a242c5d4 chore(deps): update github actions 2026-05-25 15:02:49 +07:00
renovate[bot] 6bb4e70df3 chore(deps): maintain cargo lockfile 2026-05-25 01:56:28 +00:00
Nils Andreas Svee c739764476 ci: split actionlint into separate steps for handwritten and generated workflows
Disable ShellCheck globally for the cargo-dist generated release workflow to replace brittle regex ignore patterns with a cleaner configuration.
2026-05-18 13:50:18 +07:00
Nils Andreas Svee 5c96024c4f ci: simplify actionlint ignore patterns for generated release workflow
Update the regex patterns to be more robust and improve the clarity of comments regarding cargo-dist generated workflows.
2026-05-18 13:46:26 +07:00
Nils Andreas Svee 5d653dac13 fix(client): remove duplicate constant exports and definitions 2026-05-18 11:12:21 +07:00
Nils Andreas Svee 6f77d38a7f docs: document client errors and bounds 2026-05-18 10:56:58 +07:00
Nils Andreas Svee 01c10e2e0d docs: document client public API 2026-05-18 01:34:04 +07:00
Nils Andreas Svee 8fd0b803e9 docs: add public crate landing docs 2026-05-18 01:22:08 +07:00
Nils Andreas Svee 614ad5b5cd ci: ignore specific shellcheck warnings in generated release workflow
Suppress SC2086 and SC2129 linting errors for the generated release.yml to prevent build noise while maintaining checks for logic issues.
2026-05-18 01:05:40 +07:00
Nils Andreas Svee 2fcef0adc5 docs: clarify params validation boundaries 2026-05-18 00:36:18 +07:00
Nils Andreas Svee c7a0e5d055 chore: tighten release readiness checks 2026-05-18 00:30:07 +07:00
Nils Andreas Svee e117813578 test: consolidate and prune redundant unit tests
Simplify the test suite by merging similar test cases and removing redundant assertions that verify trivial initialization and basic container logic.
2026-05-18 00:16:51 +07:00
Nils Andreas Svee 3b5336a487 refactor(client): use SignedDuration for one-way delay to support negative values
Allow one-way delay samples to represent negative values caused by wall-clock skew between client and server. Update CLI output and statistics collection to correctly handle and report these signed durations.
2026-05-17 23:55:01 +07:00
Nils Andreas Svee 0b16ee802d fix(stats): support sequence number wraparound in IPDV tracking
Use wrapping arithmetic instead of checked arithmetic to ensure inter-packet delay variation (IPDV) is correctly calculated when sequence numbers wrap from u32::MAX to 0.
2026-05-17 23:11:26 +07:00
Nils Andreas Svee 64da60d480 feat(proto): enforce maximum length for server fill parameter
Move MAX_SERVER_FILL_BYTES to irtt-proto and validate it during decoding to prevent processing oversized parameters.
2026-05-17 22:59:57 +07:00
Nils Andreas Svee 2d6581f31f refactor(client): encapsulate SignedDuration and enhance its API
Hide internal fields to improve type safety and provide helper methods for unit conversions and negative value checks. Fix several feature-gated test compilation issues.
2026-05-17 22:47:16 +07:00
Nils Andreas Svee bec56d4114 refactor(client): use SignedDuration for effective RTT to support negative values
Simplify RttSample by merging signed and unsigned fields into a single SignedDuration for adjusted and effective RTT. This ensures accurate reporting and statistics when server processing time exceeds raw RTT.
2026-05-17 21:30:51 +07:00
Nils Andreas Svee 37de7c96a1 test(client): remove redundant probe/reply tests 2026-05-17 15:22:08 +07:00
Nils Andreas Svee 6c5972aa58 test(client): refactor probes and replies tests into submodules
Split the monolithic `probes_replies.rs` file into logical submodules and expose internal packet processing methods to improve test maintainability and coverage of boundary conditions.
2026-05-17 14:54:59 +07:00
renovate[bot] 55034fc23c chore(deps): update github actions 2026-05-17 14:49:03 +07:00
Nils Andreas Svee bed877760e test: prune client tests that assert private implementation details 2026-05-17 14:48:45 +07:00
renovate[bot] 87e1755a4b chore(deps): pin dependencies 2026-05-16 20:39:57 +00:00
Nils Andreas Svee 39d414d588 test: consolidate formatter and hub policy cases 2026-05-17 00:39:23 +07:00
Nils Andreas Svee 00fd0ebb15 test: consolidate duplicate CLI and client config cases 2026-05-17 00:34:18 +07:00
Nils Andreas Svee ddd420cc21 test: consolidate and prune implementation-coupled tests 2026-05-17 00:28:05 +07:00
Nils Andreas Svee a7288cc612 test(client): extract backend smoke tests into a dedicated module 2026-05-17 00:17:10 +07:00
Nils Andreas Svee fdcdb3b9d7 test(client): remove implementation-coupled client tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee bd9ed7eb88 test(client): trim implementation-coupled probe tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee 5d471b77fb refactor(irtt-cli): split stream runtime path 2026-05-13 22:20:51 +07:00
Nils Andreas Svee 61c527eb26 refactor(irtt-cli): rename stream output helper 2026-05-13 22:12:03 +07:00
Nils Andreas Svee f5c35e8142 feat(irtt-cli): note running-only continuous summaries 2026-05-13 22:05:18 +07:00
Nils Andreas Svee 9adab6882d test(cli): add tests for interrupted continuous mode summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee 67ef912ba0 feat(cli): print summary when continuous mode is interrupted 2026-05-13 22:01:56 +07:00
Nils Andreas Svee f2f5728c72 refactor(cli): rename print_finite_summary to print_final_summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee c19dac5aef docs(irtt-cli): expand duration flag documentation
Add detailed help text to clarify the difference in statistics handling between finite and continuous test modes.
2026-05-13 21:36:30 +07:00
Nils Andreas Svee 480cb4fa92 warn(irtt-cli): flag large finite stats runs 2026-05-13 20:18:45 +07:00
Nils Andreas Svee ea85384ced docs(irtt-stats): clarify event contribution for rolling_count 2026-05-13 16:58:12 +07:00
Nils Andreas Svee aa6c3f4fa6 refactor(irtt-stats): remove unknown loss field 2026-05-13 16:25:11 +07:00
Nils Andreas Svee dd1b08c51c docs(irtt-stats): enforce public API documentation 2026-05-13 15:26:37 +07:00
Nils Andreas Svee a180388dc6 docs(irtt-stats): Add documentation comments to public-facing types and fields
Add docstrings to clarify the purpose of statistics collection types and describe the units and behaviors of metric fields.
2026-05-13 15:15:41 +07:00
Nils Andreas Svee 19e0e7aab9 refactor(irtt-stats): extract core aggregation module 2026-05-13 14:57:19 +07:00
Nils Andreas Svee c26008a0b6 refactor(irtt-stats): extract event normalization module 2026-05-13 14:53:13 +07:00
Nils Andreas Svee 1469a2d9e8 refactor(irtt-stats): extract rolling events module 2026-05-13 14:44:56 +07:00
Nils Andreas Svee ebc0d46621 refactor(irtt-stats): extract loss stats module 2026-05-13 14:32:12 +07:00
Nils Andreas Svee cc43b43f00 refactor(irtt-stats): extract IPDV tracking module 2026-05-13 14:25:54 +07:00
Nils Andreas Svee 04d5b634a2 refactor(irtt-stats): extract time stats module 2026-05-13 14:16:07 +07:00
Nils Andreas Svee 557c946796 refactor(irtt-stats): split snapshot builders 2026-05-13 14:07:43 +07:00
Nils Andreas Svee bda1d83c27 refactor(irtt-stats): extract rolling event storage 2026-05-13 13:59:54 +07:00
Nils Andreas Svee 820304f6eb refactor(irtt-stats): split unique reply accounting 2026-05-13 13:49:43 +07:00
Nils Andreas Svee f211845645 refactor(irtt-stats): extract IPDV update recording 2026-05-13 13:45:28 +07:00
Nils Andreas Svee 851825822d refactor(irtt-stats): split CoreStats event handlers 2026-05-13 13:40:55 +07:00
Nils Andreas Svee 969d26d0f1 refactor(irtt-stats): extract event normalization boundary 2026-05-13 13:31:27 +07:00
Nils Andreas Svee c1b2486046 refactor(irtt-stats): split reply and IPDV samples 2026-05-13 13:28:08 +07:00
Nils Andreas Svee 90bb29cd75 refactor(irtt-stats): extract IPDV tracker 2026-05-13 13:20:58 +07:00
Nils Andreas Svee 0dd137a59e refactor(irtt-stats): rename WindowEvent to StatsEvent 2026-05-13 13:12:06 +07:00
Nils Andreas Svee 05f9732760 refactor(irtt-stats): rename median mode to sample mode 2026-05-13 12:58:13 +07:00
Nils Andreas Svee ce4b3c37ab fix(irtt-stats): keep RTT IPDV independent from directional timing 2026-05-13 12:57:10 +07:00
Nils Andreas Svee f05b7cceb2 refactor(irtt-stats): improve duration safety by handling negative time offsets 2026-05-13 12:44:35 +07:00
Nils Andreas Svee 0a66f6fb49 refactor(irtt-stats): remove unused push_duration helper 2026-05-13 12:36:51 +07:00
Nils Andreas Svee 5138dee29f refactor(irtt-stats): unify duration statistics into a single TimeStats type
Simplify the statistics model by merging signed and unsigned duration types into a single structure that uses i128 to consistently handle both positive and negative time offsets.
2026-05-13 12:32:47 +07:00
Nils Andreas Svee 542e67bf74 refactor(irtt-stats): consolidate snapshot types and rename summary to snapshot 2026-05-13 12:21:51 +07:00
Nils Andreas Svee 197903fb8e test(irtt-stats): rename internal date-time helper and update test case name
Rename system_time_ns to unix_time_ns_after_epoch for better clarity on output behavior and update a continuous mode test case name to more accurately reflect its purpose.
2026-05-13 12:07:51 +07:00
Nils Andreas Svee adfe8e7a6b test(irtt-stats): move integration tests to dedicated test module 2026-05-13 11:59:06 +07:00
Nils Andreas Svee 72470bd196 test(client): update and expand DSCP negotiation test coverage 2026-05-13 00:37:54 +07:00
Nils Andreas Svee fec14b2c7c feat(session): restrict DSCP negotiation to allow only disabling (reset to 0) 2026-05-13 00:37:53 +07:00
Nils Andreas Svee 3dfca1ab5e refactor(session): implement Display and add documentation for NegotiationRestriction 2026-05-13 00:37:45 +07:00
Nils Andreas Svee cbe91f2fe0 feat(client): track and report specific negotiation restrictions
Replace basic validation with a detailed negotiation process that records specific parameter changes. This allows the client to provide better feedback when a server restricts requested session parameters like duration or DSCP.
2026-05-12 15:18:02 +07:00
Nils Andreas Svee a7e10358fd test(client): use fallible packet length helpers in integration tests 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 4549d95d0a test(client): update test helpers to handle fallible packet length calculations 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 1b79c864d4 refactor(client): propagate ProtoError from recv_buffer_size calculation 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 20a1d9dc8e refactor(proto): make echo_packet_len return Result to handle negative lengths 2026-05-12 13:05:45 +07:00
Nils Andreas Svee b508a94198 build: bump version to 0.2.0 2026-05-11 15:11:58 +07:00
Nils Andreas Svee 4ed09e2209 ci: add cargo-dist for automated release management
Introduce cargo-dist workflows and configuration to automate binary building and GitHub Release creation across multiple platforms.
2026-05-11 15:10:15 +07:00
Nils Andreas Svee e2a9d68843 ci: add actions read permission to OSV-Scanner workflow
Grant necessary read access to actions to ensure the vulnerability scanner can evaluate the execution environment.
2026-05-11 15:01:05 +07:00
renovate[bot] c04e8f7084 chore(deps): maintain cargo lockfile 2026-05-11 08:00:31 +00:00
renovate[bot] 323e5f7f3d chore(deps): update github actions 2026-05-11 14:58:04 +07:00
Nils Andreas Svee 7ad83ca1f6 refactor: remove logical_seq from client events, session tracking and stats usage
It was an attempt at handling integer wraparound more cleanly keeping in mind that we have a continuous mode,
and so we can more easily reach seq > u32::MAX

However after running the numbers, if we run with a interval of 10ms, it's still gonna take ~497 days before we hit integer overflow, so for now it doesn't seem worth to have this extra logic to handle something that'll in 99.9% of cases be purely an edge case

Keeping in mind autorate usage, in sqm-autorate-rust each send loop is 500 ms by default, and the sleep is then 500 ms / 5 reflectors, so our interval is 100 ms. that means it'll take 4970 days or ~13.6 years to reach the limit. Not worth dealing with IMO
2026-05-11 14:56:07 +07:00
Nils Andreas Svee 8ef53c999a build: optimize release profile for binary size and performance
Enable LTO, single codegen units, and symbol stripping to reduce binary footprint and improve execution efficiency.
2026-05-11 13:27:50 +07:00
Nils Andreas Svee 21bc7805f7 refactor: mark public enums as non-exhaustive to improve API stability
Apply the #[non-exhaustive] attribute to ClientError and WarningKind to prevent breaking changes when adding new variants. Handle the catch-all case in the CLI formatter.
2026-05-11 12:33:40 +07:00
Nils Andreas Svee 8d1d0f50de style(client): reformat imports in test files
Collapse multi-line import statements into single lines to improve code legibility and maintain consistent formatting across test suites.
2026-05-11 10:13:06 +07:00
Nils Andreas Svee d6c6e60742 refactor(client): expose len methods for map structures 2026-05-11 10:10:24 +07:00
Nils Andreas Svee 1cdd100473 refactor(irtt-stats): remove redundant process_with_update method
Simplify the StatsCollector API by removing an unnecessary indirection that performed the same logic as the main process method.
2026-05-11 10:09:13 +07:00
Nils Andreas Svee 80fda92231 fix(irtt-stats): use event timestamp for warnings instead of current time
Ensure warning statistics reflect the actual time the event occurred rather than the moment the event was processed.
2026-05-11 09:55:11 +07:00
Nils Andreas Svee 5f6bff5dcb feat(client): add timestamp to ClientEvent::Warning
Include a timestamp when reporting warnings to allow consumers like the machine-readable formatter to provide temporal context for troubleshooting.
2026-05-11 09:50:16 +07:00
Nils Andreas Svee 1a04f44bb5 refactor(client): remove redundant timestamp parameter from poll_timeouts
Use internal monotonic time by default and introduce poll_timeouts_at for callers requiring a specific sampled Instant. This simplifies the API for standard usage while maintaining flexibility for external event loops.
2026-05-11 09:33:55 +07:00
Nils Andreas Svee d8b580a19c refactor(client): remove redundant timestamp parameter from close method 2026-05-10 23:47:46 +07:00
Nils Andreas Svee 023e85741a refactor(client): remove redundant timestamp parameter from open method 2026-05-10 23:37:35 +07:00
107 changed files with 19682 additions and 7585 deletions

No files matched your search

+19 -5
View File
@@ -23,13 +23,27 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
zizmor:
name: zizmor
runs-on: ubuntu-latest
@@ -42,11 +56,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
persona: regular
+47 -8
View File
@@ -24,12 +24,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
components: rustfmt
cache-key: fmt
@@ -44,12 +44,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
components: clippy
cache-key: clippy
@@ -63,6 +63,38 @@ jobs:
--locked \
-- -D warnings
- name: Run clippy without default features
run: |
cargo clippy \
--workspace \
--all-targets \
--no-default-features \
--locked \
-- -D warnings
check:
name: cargo check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache-key: check
- name: Run check without default features
run: |
cargo check \
--workspace \
--no-default-features \
--locked
test:
name: cargo test
runs-on: ubuntu-latest
@@ -70,12 +102,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache-key: test
@@ -87,6 +119,13 @@ jobs:
--all-features \
--locked
- name: Run tests without default features
run: |
cargo test \
--workspace \
--no-default-features \
--locked
docs:
name: cargo doc
runs-on: ubuntu-latest
@@ -97,12 +136,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache-key: docs
+75
View File
@@ -0,0 +1,75 @@
name: Netem Interop
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
GOBIN: ${{ github.workspace }}/.tools/bin
IRTT_UPSTREAM_VERSION: v0.9.1
jobs:
netem:
name: netem interop
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache-key: interop-netem
- name: Check host tools
run: |
ip -V
tc -V
go version
- name: Install upstream irtt
run: |
mkdir -p "$GOBIN"
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
"$GOBIN/irtt" version
- name: Build irtt-rs CLI
run: cargo build -p irtt-cli --locked
- name: Run netem interop comparisons
run: |
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$GOBIN/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
- name: Append netem interop summary
if: always()
run: |
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
if [ -n "$latest_summary" ]; then
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
else
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload netem interop artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: interop-netem
path: target/interop-netem
+7 -6
View File
@@ -13,24 +13,25 @@ name: OSV-Scanner
on:
pull_request:
branches: [ "main" ]
branches: ["main"]
merge_group:
branches: [ "main" ]
branches: ["main"]
schedule:
- cron: '45 8 * * 1'
- cron: "45 8 * * 1"
push:
branches: [ "main" ]
branches: ["main"]
permissions:
# Require writing security events to upload SARIF file to security tab
security-events: write
# Read commit contents
contents: read
actions: read
jobs:
scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
# Example of specifying custom arguments
scan-args: |-
@@ -39,7 +40,7 @@ jobs:
./
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@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
# Example of specifying custom arguments
scan-args: |-
+296
View File
@@ -0,0 +1,296 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
+3 -3
View File
@@ -24,12 +24,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
components: clippy
cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
+20 -5
View File
@@ -38,12 +38,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
with:
command: check
arguments: --all-features
@@ -56,9 +56,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
Generated
+1573 -41
View File
File diff suppressed because it is too large. Load diff
+16 -4
View File
@@ -10,12 +10,24 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "Apache-2.0"
rust-version = "1.75"
rust-version = "1.85"
repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.1.1", path = "crates/irtt-proto" }
irtt-client = { version = "0.1.1", path = "crates/irtt-client" }
irtt-stats = { version = "0.1.1", path = "crates/irtt-stats" }
irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"
+138 -18
View File
@@ -10,17 +10,21 @@ It is not the upstream IRTT project. For the original implementation, protocol b
This repository currently provides:
- `irtt-cli`, a command-line IRTT-compatible client
- `irtt-rs`, the canonical multi-applet binary
- `irtt-cli`, a stream/text IRTT-compatible client applet
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
- `irtt-client`, a Rust library for running client sessions and consuming events
- finite and continuous probe runs
- human, simple, machine-readable, and RTT-only output modes
- table, CSV, TSV, and JSON Lines event-row output formats with selectable columns
- optional local summary statistics
Server support is not implemented.
The installed binary is intentionally not named `irtt` to avoid conflicting or
confusing overlap with upstream IRTT.
## Install
Requires Rust 1.75 or newer.
Requires Rust 1.85 or newer.
From a local checkout:
@@ -30,12 +34,48 @@ cd irtt-rs
cargo install --path crates/irtt-cli
```
The default install provides the normal client-oriented binaries, including
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
```sh
cargo install --path crates/irtt-cli --features tui
```
Or run it directly without installing:
```sh
cargo run -p irtt-cli -- <server>
cargo run -p irtt-cli --bin irtt-cli -- <server>
```
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
only `irtt-rs` and symlink or hardlink enabled applet names such as `irtt-cli`
and, when built with the `tui` feature, `irtt-tui` to it. Applet dispatch is
based on the invoked binary name. Server support is not implemented.
cargo-dist release archives are currently configured with the `full` feature
set, so target archives bundle the enabled applet binaries: `irtt-rs`,
`irtt-cli`, and `irtt-tui`. That is separate from space-sensitive package
layouts, which may still ship one dispatcher binary plus applet-name symlinks
or hardlinks. cargo-dist is not currently configured to create those links.
## Build verification
Useful local release/package sanity checks:
```sh
cargo fmt --check
cargo test -p irtt-cli
cargo clippy --workspace --all-targets -- -D warnings
cargo build -p irtt-cli --release
cargo build -p irtt-cli --no-default-features --release
cargo build -p irtt-cli --features tui --release
cargo build -p irtt-cli --all-features --release
```
The no-default-features build only provides the `irtt-rs` dispatcher binary;
the default build provides `irtt-rs` and `irtt-cli`; `tui`, `full`, and
all-features builds also provide `irtt-tui`.
## CLI usage
Basic test:
@@ -56,43 +96,123 @@ Run continuously:
irtt-cli <server> --duration 0
```
Use a specific output mode:
Probe multiple targets concurrently:
```sh
irtt-cli <server> --output human
irtt-cli <server> --output simple
irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us
irtt-cli host-a:2112 host-b:2112
irtt-cli --target ams=ams.example.com:2112 --target sg=sg.example.com:2112
irtt-cli host-a:2112 host-b:2112 --pacing burst
```
For positional multi-target runs, the original target string is used as the
logical target label. Repeated positional strings get a stable numeric suffix.
For `--target label=host:port`, `label` is used in the `target` output column.
Labels must be unique. `--pacing staggered|burst` selects managed group pacing
for multi-target runs; the default is `staggered`.
Use a specific output format or column selection:
```sh
irtt-cli <server> --format table
irtt-cli <server> --format jsonl
irtt-cli <server> --format csv --columns event,seq,remote,effective_rtt_us
irtt-cli host-a:2112 host-b:2112 --format csv --columns target,seq,effective_rtt_us
irtt-cli <server> --format tsv --columns effective_rtt_us --header never
irtt-cli --list-columns
```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
interactive probing. It defaults to continuous probing, equivalent to
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
session gracefully.
```sh
irtt-tui <server>
irtt-tui <server> --duration 30s
irtt-tui host-a:2112 host-b:2112 --pacing burst
irtt-tui --target ams=ams.example.com:2112 --target sg=sg.example.com:2112
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
```
For available options:
```sh
irtt-cli --help
irtt-tui --help # when built or installed with the tui feature
```
## Machine output
## Event row output
Use `--output machine` for line-oriented `key=value` output intended for scripts, monitoring, and autorate consumers.
The stream client separates event row rendering from final summaries. Event rows
are controlled by `--format`, `--columns`, and `--header`; final summaries remain
separate table-style text and are printed for table output when the run policy
allows a summary.
Formats:
- `table`: default, terminal-readable columns with a header
- `csv`: comma-separated rows, intended for scripts
- `tsv`: tab-separated rows, useful for one-value streams and shell pipelines
- `jsonl`: one JSON object per event row, suitable for streaming consumers
Headers are controlled with `--header auto|always|never`. In `auto`, table, CSV,
and TSV print one header row; JSON Lines never prints a header.
Columns are selected with `-c, --columns <COLUMNS>`, where `COLUMNS` is a
comma-separated list. Use `--list-columns` to print the available names. Useful
columns include:
```text
target, event, seq, remote, token, rtt, rtt_us, raw_rtt_us, effective_rtt_us,
adjusted_rtt_us, rd, rd_us, sd, sd_us, ipdv, ipdv_us, proc,
server_processing_us, bytes, send_call_us, timer_error_us, highest_seen,
server_received, server_window, dscp, ecn, traffic_class, kernel_rx_ns,
warning_kind, message, event_wall_ns, client_send_wall_ns,
client_receive_wall_ns
```
Aliases are accepted for readability: `receive_delay` and `receive_delay_us`
for `rd` and `rd_us`, `send_delay` and `send_delay_us` for `sd` and `sd_us`,
`server_processing` for `proc`, `server_received_count` for
`server_received`, and `server_received_window` for `server_window`.
The default single-target table output favors readability with compact columns:
`event,seq,rtt,rd,sd,ipdv,proc,message`. It omits `echo_sent` rows. Passing
`--columns default` is the same as omitting `--columns`, including the default
table row filtering. Multi-target defaults add `target` as the first column so
rows can be attributed to a logical target label. Custom table column selections
include all event rows. CSV, TSV, and JSONL default to all non-target columns
for single-target export and include `target` by default for multi-target
export; use `--columns all` explicitly to request every column in any format.
Missing table values render as `-`; missing CSV and TSV values render as empty
fields; missing JSONL values render as `null`.
Use JSON Lines for structured event streaming:
Example:
```sh
irtt-cli <server> --duration 0 --interval 250ms --output machine
irtt-cli <server> --duration 0 --interval 250ms --format jsonl
```
Each line represents one client event and starts with an `event` field, for example:
Each line is one client event object, for example:
```text
event=echo_reply seq=4 logical_seq=4 remote=203.0.113.10:2112 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_processing_us=300 dscp=0 ecn=0 kernel_rx_ns=none
```json
{"event":"echo_reply","seq":4,"remote":"203.0.113.10:2112","token":null,"rtt":"12.1ms","rtt_us":12100,"raw_rtt_us":12400,"effective_rtt_us":12100,"adjusted_rtt_us":12100,"rd":"6.0ms","rd_us":6000,"sd":"6.1ms","sd_us":6100,"ipdv":"300.0µs","ipdv_us":300,"proc":"300.0µs","server_processing_us":300,"bytes":64,"send_call_us":null,"timer_error_us":null,"highest_seen":null,"server_received":5,"server_window":"0x1f","dscp":0,"ecn":0,"traffic_class":0,"kernel_rx_ns":null,"warning_kind":null,"message":null,"event_wall_ns":1760000000012400000,"client_send_wall_ns":1760000000000000000,"client_receive_wall_ns":1760000000012400000}
```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
Consumers should match on the `event` property and read the columns they need.
RTT, send-call, and timer-error `*_us` fields are reported in microseconds.
`raw_rtt_us` is the measured client send-to-receive RTT. `effective_rtt_us` and
`adjusted_rtt_us` are signed and may be negative when server processing exceeds
the raw RTT or timing correction produces a negative adjusted value. When
one-way delay fields are present, `sd_us` and `rd_us` are also signed
microseconds and may be negative due to wall-clock skew.
For consumers that only need RTT values, use:
For consumers that only need effective RTT values in microseconds, use:
```sh
irtt-cli <server> --output rtt-us
irtt-cli <server> --format tsv --columns effective_rtt_us --header never
```
## Library usage
+28 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-cli"
version = "0.1.1"
version = "0.4.0"
description = "Command-line IRTT-compatible client"
edition.workspace = true
license.workspace = true
@@ -11,13 +11,36 @@ readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
doc = false
[[bin]]
name = "irtt-rs"
path = "src/bin/irtt-rs.rs"
[[bin]]
name = "irtt-cli"
path = "src/bin/irtt-cli.rs"
required-features = ["client"]
[[bin]]
name = "irtt-tui"
path = "src/bin/irtt-tui.rs"
required-features = ["tui"]
[features]
default = ["stats"]
stats = ["dep:irtt-stats"]
default = ["client", "stats"]
client-runtime = ["dep:irtt-client", "dep:irtt-proto"]
client = ["client-runtime"]
stats = ["client-runtime", "dep:irtt-stats"]
tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"]
full = ["client", "stats", "tui"]
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.29.0", optional = true }
ctrlc = "3.5.2"
irtt-client.workspace = true
irtt-proto.workspace = true
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.30.2", optional = true }
+222
View File
@@ -0,0 +1,222 @@
use std::{ffi::OsString, path::Path};
use clap::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppletDispatch {
Run {
applet: RequestedApplet,
argv: Vec<OsString>,
},
Help(String),
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-cli" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
if let Some(applet) = detect_applet_from_argv0(argv0) {
return Ok(AppletDispatch::Run { applet, argv });
}
let basename = applet_basename(argv0);
if basename.starts_with("irtt-") && basename != "irtt-rs" {
return Err(format!(
"unknown applet name '{basename}'. Known applet names: {}",
known_applet_binary_names()
));
}
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
return Err(format!(
"choose an applet: {}\n\n{}",
applet_command_names(),
dispatcher_help()
));
};
match command {
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: applet_argv("irtt-cli", &argv[2..]),
}),
"tui" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: applet_argv("irtt-tui", &argv[2..]),
}),
"server" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Server,
argv: applet_argv("irtt-server", &argv[2..]),
}),
_ => Err(format!(
"unknown applet '{command}'. Choose one of: {}\n\n{}",
applet_command_names(),
dispatcher_help()
)),
}
}
pub fn dispatcher_help() -> String {
let mut command = Command::new("irtt-rs")
.about("IRTT-compatible multi-applet dispatcher")
.subcommand(Command::new("client").about(client_applet_about()))
.subcommand(Command::new("tui").about(tui_applet_about()))
.subcommand(Command::new("server").about("Server applet is not available in this build"))
.after_help(dispatcher_after_help());
command.render_help().to_string()
}
fn client_applet_about() -> &'static str {
if cfg!(feature = "client") {
"Run the stream client applet"
} else {
"Stream client applet is not available in this build"
}
}
fn tui_applet_about() -> &'static str {
if cfg!(feature = "tui") {
"Run the terminal UI applet"
} else {
"Terminal UI applet is not available in this build"
}
}
fn known_applet_binary_names() -> &'static str {
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
}
fn applet_command_names() -> &'static str {
"client, tui, or server"
}
fn dispatcher_after_help() -> &'static str {
if cfg!(feature = "tui") {
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server"
} else if cfg!(feature = "client") {
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applet: irtt-tui requires the tui feature"
} else {
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applets: irtt-cli requires the client feature; irtt-tui requires the tui feature"
}
}
fn applet_basename(argv0: &str) -> &str {
Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0)
}
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push(OsString::from(applet_name));
argv.extend(args.iter().cloned());
argv
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-cli"),
Some(RequestedApplet::Client)
);
assert_eq!(
detect_applet_from_argv0("irtt-tui"),
Some(RequestedApplet::Tui)
);
assert_eq!(
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn canonical_help_renders_dispatcher_help() {
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
let AppletDispatch::Help(help) = dispatch else {
panic!("expected dispatcher help");
};
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
assert!(help.contains("client"));
if cfg!(feature = "tui") {
assert!(help.contains("Run the terminal UI applet"));
} else {
assert!(help.contains("Terminal UI applet is not available in this build"));
}
assert!(help.contains("server"));
}
#[test]
fn canonical_without_applet_is_an_error() {
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
assert!(err.contains("choose an applet"));
assert!(err.contains("client"));
if cfg!(feature = "tui") {
assert!(err.contains("tui"));
}
assert!(err.contains("server"));
}
#[test]
fn canonical_subcommands_dispatch_to_applet_argv() {
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
.unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: vec!["irtt-cli".into(), "host:2112".into()],
}
);
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: vec!["irtt-tui".into(), "host:2112".into()],
}
);
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Server,
argv: vec!["irtt-server".into()],
}
);
}
#[test]
fn unknown_irtt_binary_name_is_an_error() {
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
assert!(err.contains("unknown applet name 'irtt-typo'"));
assert!(err.contains("irtt-cli"));
if cfg!(feature = "tui") {
assert!(err.contains("irtt-tui"));
}
assert!(err.contains("irtt-server"));
}
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
}
+319
View File
@@ -0,0 +1,319 @@
use std::time::Duration;
use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig;
use crate::shared::client::{
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs,
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec, TimestampArg,
};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-cli host-a:2112 host-b:2112."
)]
pub targets: Vec<String>,
/// Explicit labelled target, as label=host:port. Repeat for multi-target mode.
#[arg(
long = "target",
value_name = "LABEL=TARGET",
value_parser = parse_labelled_target,
long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used as the target column and must be unique."
)]
pub labelled_targets: Vec<LabelledTargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
default_value = "10s",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Event row output format.
#[arg(
long,
value_enum,
default_value_t = OutputFormat::Table,
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
)]
pub format: OutputFormat,
/// Comma-separated event row columns, or default/all.
#[arg(
short = 'c',
long,
value_name = "COLUMNS",
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
)]
pub columns: Option<String>,
/// List available event row columns and aliases, then exit.
#[arg(
long,
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
)]
pub list_columns: bool,
/// Header policy for table, CSV, and TSV output.
#[arg(
long,
value_enum,
default_value_t = HeaderMode::Auto,
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
)]
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(
self.primary_target_addr()
.expect("target is required unless --list-columns is set"),
self.duration,
)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
pub fn target_specs(&self) -> Result<Vec<CliTargetSpec>, String> {
target_specs(&self.targets, &self.labelled_targets)
}
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedCliTarget>, String> {
let specs = self.target_specs()?;
let config = self.to_client_config();
resolved_managed_targets(specs, &config)
}
fn primary_target_addr(&self) -> Option<&str> {
self.targets.first().map(String::as_str).or_else(|| {
self.labelled_targets
.first()
.map(|target| target.addr.as_str())
})
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// Readable terminal table output.
Table,
/// Comma-separated event rows.
Csv,
/// Tab-separated event rows.
Tsv,
/// One JSON object per event row.
Jsonl,
}
pub type CliTargetSpec = TargetSpec;
pub type ResolvedCliTarget = ResolvedTarget;
impl OutputFormat {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HeaderMode {
/// Print headers for table, CSV, and TSV output.
Auto,
/// Always print headers where the format supports them.
Always,
/// Never print headers.
Never,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets, ["127.0.0.1:2112"]);
assert!(args.labelled_targets.is_empty());
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.to_client_config();
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert!(args.targets.is_empty());
assert!(args.list_columns);
}
#[test]
fn multiple_positional_targets_parse_as_targets() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].addr, "host-b:2112");
}
#[test]
fn repeated_labelled_targets_parse() {
let args = parse(&[
"--target",
"ams=ams.example.com:2112",
"--target",
"sg=sg.example.com:2112",
])
.unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].addr, "sg.example.com:2112");
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap();
let err = args.target_specs().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_resolved_target_addresses_are_rejected() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
let err = args.resolved_managed_targets().unwrap_err();
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["--target", "missing-equals"]).is_err());
assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err());
assert!(parse(&["--target", "label="]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--format <FORMAT>"));
assert!(help.contains("--target <LABEL=TARGET>"));
assert!(help.contains("--pacing <PACING>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputFormat::Jsonl.prints_summary());
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod args;
pub mod output;
pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use run::run_stream;
File diff suppressed because it is too large. Load diff
+766
View File
@@ -0,0 +1,766 @@
#[cfg(feature = "stats")]
use std::collections::BTreeMap;
use std::{
collections::HashSet,
io::{self, Write},
sync::atomic::AtomicBool,
thread,
time::{Duration, Instant},
};
use irtt_client::{
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow, TargetEvent,
};
#[cfg(all(test, feature = "stats"))]
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{
args::{ClientArgs, ResolvedCliTarget},
output::{EventRenderStats, OutputConfig},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested, session::should_print_final_summary, ClientSession,
};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
#[cfg(feature = "stats")]
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
#[cfg(feature = "stats")]
const MIB: u64 = 1024 * 1024;
#[cfg(feature = "stats")]
const GIB: u64 = 1024 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const GROUP_IDLE_SLEEP: Duration = Duration::from_millis(5);
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
if args.list_columns {
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let output_config = OutputConfig::new(
args.format,
args.columns.as_deref(),
args.header,
args.verbose,
args.target_specs()
.map(|targets| targets.len() > 1)
.unwrap_or(false),
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let targets = args
.resolved_managed_targets()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let multi_target = targets.len() > 1;
let continuous = args.is_continuous();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
eprintln!("{warning}");
}
if multi_target {
return run_group_stream(args, targets, output_config, continuous, shutdown_requested);
}
let target_label = targets
.first()
.map(|target| target.label.as_str())
.expect("at least one target was validated");
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
#[cfg(feature = "stats")]
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
#[cfg(not(feature = "stats"))]
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
let mut interrupted = false;
while session.should_continue(shutdown_requested) {
let events = session.step(shutdown_requested)?;
#[cfg(feature = "stats")]
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
#[cfg(not(feature = "stats"))]
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
session.sleep_until_next_send();
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if session.should_drain_final(interrupted) {
session.drain_final(|events| {
#[cfg(feature = "stats")]
let _ =
print_events_with_stats(&mut stream_output, events, Some(target_label), &mut stats);
#[cfg(not(feature = "stats"))]
let _ = print_events_with_stats(&mut stream_output, events, Some(target_label));
})?;
}
let events = session.poll_timeouts()?;
#[cfg(feature = "stats")]
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
#[cfg(not(feature = "stats"))]
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
let events = session.close()?;
#[cfg(feature = "stats")]
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
#[cfg(not(feature = "stats"))]
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
#[cfg(feature = "stats")]
stream_output.print_summary(&stats)?;
#[cfg(not(feature = "stats"))]
stream_output.print_summary()?;
stream_output.out.flush()?;
Ok(())
}
struct StreamOutput<'a, W: Write> {
config: OutputConfig,
header_printed: bool,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(
&mut self,
events: &[ClientEvent],
target: Option<&str>,
stats_updates: &[EventRenderStats],
) -> io::Result<()> {
self.print_header()?;
for (event, stats_update) in events.iter().zip(stats_updates) {
self.print_event(event, target, stats_update)?;
}
Ok(())
}
fn print_event(
&mut self,
event: &ClientEvent,
target: Option<&str>,
stats_update: &EventRenderStats,
) -> io::Result<()> {
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_header(&mut self) -> io::Result<()> {
if self.header_printed {
return Ok(());
}
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
#[cfg(feature = "stats")]
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if !self.print_final_summary || !self.config.prints_summary() {
return Ok(());
}
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note,
},
)
)?;
Ok(())
}
#[cfg(not(feature = "stats"))]
fn print_summary(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(feature = "stats")]
fn print_events_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
events: &[ClientEvent],
target: Option<&str>,
stats: &mut StatsCollector,
) -> io::Result<()> {
let stats_updates = events
.iter()
.map(|event| EventRenderStats::from(stats.process(event)))
.collect::<Vec<_>>();
stream_output.print_events(events, target, &stats_updates)
}
#[cfg(not(feature = "stats"))]
fn print_events_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
events: &[ClientEvent],
target: Option<&str>,
) -> io::Result<()> {
let stats_updates = vec![EventRenderStats::default(); events.len()];
stream_output.print_events(events, target, &stats_updates)
}
fn run_group_stream(
args: ClientArgs,
targets: Vec<ResolvedCliTarget>,
output_config: OutputConfig,
continuous: bool,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = args.to_client_config();
let managed_targets = targets
.iter()
.map(|target| target.managed.clone())
.collect::<Vec<_>>();
let expected_target_count = managed_targets.len();
if let Some(first) = managed_targets.first() {
config.server_addr = first.remote.to_string();
}
let group_config = ManagedClientGroupConfig {
client: config,
pacing: args.pacing.into(),
};
let (session, events) = ManagedClientGroup::start_with_subscription(
group_config,
managed_targets,
SubscriberConfig {
capacity: 16_384,
// CLI output is best-effort under sustained backpressure. Dropping
// stale rows keeps continuous runs attached to the running group
// instead of turning a full output queue into a disconnected
// subscriber and a potentially blocking join.
overflow: SubscriberOverflow::DropOldest,
},
)?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = targets
.iter()
.map(|target| {
(
target.label.clone(),
StatsCollector::new(stats_config(continuous)),
)
})
.collect::<BTreeMap<_, _>>();
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
let mut interrupted = false;
let mut terminal_targets = HashSet::new();
let mut last_event_at = Instant::now();
let mut saw_target_event = false;
loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
session.stop();
}
match events.try_recv() {
Ok(Some(target_event)) => {
last_event_at = Instant::now();
saw_target_event = true;
let label = target_event.target.as_str();
if is_terminal_target_event(&target_event.event) {
terminal_targets.insert(label.to_owned());
}
#[cfg(feature = "stats")]
{
let stats = stats
.entry(label.to_owned())
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
print_target_event_with_stats(&mut stream_output, &target_event, stats)?;
}
#[cfg(not(feature = "stats"))]
{
print_target_event_with_stats(&mut stream_output, &target_event)?;
}
}
Ok(None) => {
if interrupted {
break;
}
if terminal_targets.len() >= expected_target_count {
break;
}
if should_join_group_after_idle(&args, continuous, saw_target_event, last_event_at)
{
break;
}
thread::sleep(GROUP_IDLE_SLEEP);
}
Err(EventSubscriptionError::Disconnected) => break,
}
}
if interrupted {
eprintln!("interrupted, closing group...");
}
let outcome = session.join()?;
while let Ok(Some(target_event)) = events.try_recv() {
let label = target_event.target.as_str();
#[cfg(feature = "stats")]
{
let stats = stats
.entry(label.to_owned())
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
print_target_event_with_stats(&mut stream_output, &target_event, stats)?;
}
#[cfg(not(feature = "stats"))]
{
print_target_event_with_stats(&mut stream_output, &target_event)?;
}
}
if outcome.end_reason == ManagedGroupEndReason::Cancelled && !interrupted {
return Err("managed client group was cancelled".into());
}
let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
#[cfg(feature = "stats")]
{
for (label, stats) in &stats {
if stream_output.print_final_summary && stream_output.config.prints_summary() {
writeln!(stream_output.out)?;
writeln!(stream_output.out, "target: {label}")?;
}
stream_output.print_summary(stats)?;
}
}
#[cfg(not(feature = "stats"))]
{
stream_output.print_summary()?;
}
stream_output.out.flush()?;
Ok(())
}
fn is_terminal_target_event(event: &ClientEvent) -> bool {
matches!(
event,
ClientEvent::SessionClosed { .. } | ClientEvent::NoTestCompleted { .. }
)
}
fn estimated_group_completion_grace(args: &ClientArgs) -> Duration {
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
open_timeout
.saturating_add(args.duration)
.saturating_add(GROUP_COMPLETION_GRACE)
}
fn should_join_group_after_idle(
args: &ClientArgs,
continuous: bool,
saw_target_event: bool,
last_event_at: Instant,
) -> bool {
if continuous && saw_target_event {
return false;
}
last_event_at.elapsed() > estimated_group_completion_grace(args)
}
#[cfg(feature = "stats")]
fn print_target_event_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
target_event: &TargetEvent,
stats: &mut StatsCollector,
) -> io::Result<()> {
print_events_with_stats(
stream_output,
std::slice::from_ref(&target_event.event),
Some(target_event.target.as_str()),
stats,
)
}
#[cfg(not(feature = "stats"))]
fn print_target_event_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
target_event: &TargetEvent,
) -> io::Result<()> {
print_events_with_stats(
stream_output,
std::slice::from_ref(&target_event.event),
Some(target_event.target.as_str()),
)
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
#[cfg(feature = "stats")]
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
}
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let expected_probes = expected_probe_count(args.duration, args.interval);
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = format_bytes_for_warning(estimated_bytes);
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!(
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
))
}
#[cfg(feature = "stats")]
fn format_bytes_for_warning(bytes: u64) -> String {
if bytes >= GIB {
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
fn cli_args(args: &[&str]) -> ClientArgs {
let mut argv = vec!["irtt-rs"];
argv.extend_from_slice(args);
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
}
fn output_config(
format: crate::cmd::client::OutputFormat,
columns: Option<&str>,
header: crate::cmd::client::HeaderMode,
verbose: bool,
) -> OutputConfig {
OutputConfig::new(format, columns, header, verbose, false).unwrap()
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
config: output_config(
crate::cmd::client::OutputFormat::Tsv,
Some("effective_rtt_us"),
crate::cmd::client::HeaderMode::Never,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
};
print_events_with_stats(&mut stream_output, &events, None, &mut stats).unwrap();
stream_output.print_summary(&stats).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert!(!rendered.is_empty());
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
};
print_events_with_stats(
&mut stream_output,
&[reply_event(1, 1200)],
None,
&mut stats,
)
.unwrap();
stream_output.print_summary(&stats).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("irtt-rs summary"));
assert!(rendered.contains("medians unavailable"));
assert!(rendered.contains("continuous mode"));
assert!(rendered.contains("packets:"));
assert!(rendered.contains("received=1"));
let stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
};
stream_output.print_summary(&stats).unwrap();
}
assert!(out.is_empty());
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: SignedDuration::from_nanos(1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
for args in [
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
cli_args(&[
"--duration",
"1000ms",
"--interval",
"1ms",
"127.0.0.1:2112",
]),
] {
assert_eq!(finite_stats_memory_warning(&args), None);
}
for (threshold, size, guidance) in [
(
FINITE_STATS_MEMORY_WARNING_BYTES,
"about 128 MiB",
"bounded-memory long-running tests",
),
(
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
"about 512 MiB",
"shortening the run",
),
(
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
"about 1 GiB",
"memory-constrained systems",
),
] {
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
let args = cli_args(&[
"--duration",
&format!("{expected_probes}ms"),
"--interval",
"1ms",
"127.0.0.1:2112",
]);
let warning = finite_stats_memory_warning(&args).unwrap();
assert!(warning.contains(size));
assert!(warning.contains(guidance));
}
}
#[test]
fn expected_probe_count_rounds_up() {
assert_eq!(
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
2
);
}
#[test]
fn estimate_finite_stats_memory_bytes_saturates() {
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
}
}
@@ -1,28 +1,31 @@
use std::fmt::Write as _;
use irtt_stats::{
DurationStats, DurationStatsWithMedian, FiniteSummary, SignedDurationStatsWithMedian,
};
use irtt_stats::{Snapshot, TimeStats};
pub fn format_summary(summary: &FiniteSummary) -> String {
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
pub fn format_summary_with_options(
summary: &FiniteSummary,
options: SummaryFormatOptions,
) -> String {
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
let mut out = String::new();
let packets = summary.packets;
let loss = summary.loss;
writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
@@ -32,27 +35,27 @@ pub fn format_summary_with_options(
.unwrap();
writeln!(out, " {}", "-".repeat(82)).unwrap();
write_signed_duration_row(&mut out, "RTT", &summary.rtt.primary);
write_time_row(&mut out, "RTT", &summary.rtt.primary);
if options.verbose {
write_duration_row(&mut out, "raw RTT", &summary.rtt.raw);
write_signed_duration_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
}
write_duration_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_duration_row(&mut out, "send IPDV", &summary.ipdv.send);
write_duration_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_duration_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_duration_row(
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_time_row(
&mut out,
"receive delay",
&summary.one_way_delay.receive_delay,
);
write_duration_row_no_median(
write_time_row(
&mut out,
"server processing",
&summary.server_processing.processing,
);
write_duration_row_no_median(&mut out, "send call", &summary.send_call);
write_duration_row_no_median(&mut out, "timer error", &summary.timer_error);
write_time_row(&mut out, "send call", &summary.send_call);
write_time_row(&mut out, "timer error", &summary.timer_error);
writeln!(out).unwrap();
writeln!(
@@ -97,24 +100,7 @@ pub fn format_summary_with_options(
out
}
fn write_duration_row(out: &mut String, label: &str, value: &DurationStatsWithMedian) {
if value.stats.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.stats.count,
format_ns_u64(value.stats.min_ns),
format_ns_f64(value.stats.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_u64(value.stats.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationStats) {
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
if value.count == 0 {
return;
}
@@ -122,27 +108,10 @@ fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationS
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.count,
format_ns_u64(value.min_ns),
format_ns_i128(value.min_ns),
format_ns_f64(value.mean_ns),
"-",
format_ns_u64(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
fn write_signed_duration_row(out: &mut String, label: &str, value: &SignedDurationStatsWithMedian) {
if value.stats.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.stats.count,
format_ns_i128(value.stats.min_ns),
format_ns_f64(value.stats.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_i128(value.stats.max_ns),
format_ns_i128(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
@@ -152,12 +121,6 @@ fn format_percent(value: f64) -> String {
format!("{value:.2}%")
}
fn format_ns_u64(value: Option<u64>) -> String {
value
.map(|value| format_ns_f64(value as f64))
.unwrap_or_else(|| "-".to_owned())
}
fn format_ns_i128(value: Option<i128>) -> String {
value
.map(|value| format_ns_f64(value as f64))
@@ -188,7 +151,7 @@ mod tests {
use irtt_client::{
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
};
use irtt_stats::{SignedDurationStats, StatsCollector, StatsConfig};
use irtt_stats::{StatsCollector, StatsConfig};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
@@ -207,7 +170,7 @@ mod tests {
#[test]
fn empty_summary_omits_optional_metric_sections() {
let summary = StatsCollector::new(StatsConfig::finite()).summary();
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
let output = format_summary(&summary);
assert!(output.contains("Metric"));
@@ -229,7 +192,6 @@ mod tests {
collector.process(&ClientEvent::EchoSent {
seq: 1,
logical_seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
@@ -239,16 +201,13 @@ mod tests {
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
@@ -265,7 +224,7 @@ mod tests {
packet_meta: PacketMeta::default(),
});
let output = format_summary(&collector.summary());
let output = format_summary(&collector.snapshot());
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
assert!(output.contains("bytes: sent=64 received=64"));
@@ -279,6 +238,7 @@ mod tests {
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
#[test]
@@ -287,16 +247,13 @@ mod tests {
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
@@ -306,8 +263,11 @@ mod tests {
});
let output = format_summary_with_options(
&collector.summary(),
SummaryFormatOptions { verbose: true },
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("raw RTT"));
@@ -315,21 +275,35 @@ mod tests {
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = SignedDurationStatsWithMedian {
stats: SignedDurationStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
variance_ns2: 0.0,
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
median_ns: Some(-500.0),
variance_ns2: 0.0,
};
let mut out = String::new();
write_signed_duration_row(&mut out, "RTT", &stats);
write_time_row(&mut out, "RTT", &stats);
assert!(out.contains("-500ns"));
}
}
+6
View File
@@ -0,0 +1,6 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "tui")]
pub mod tui;
// Additional command applets belong here behind their own feature gate.
+303
View File
@@ -0,0 +1,303 @@
use std::time::Duration;
use clap::Parser;
use irtt_client::ClientConfig;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs,
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec,
};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address or host, with optional port. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
required_unless_present = "labelled_targets",
long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-tui host-a:2112 host-b:2112."
)]
pub targets: Vec<String>,
/// Explicit labelled target, as label=host:port. Repeat for multi-target mode.
#[arg(
long = "target",
value_name = "LABEL=TARGET",
value_parser = parse_labelled_target,
long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used in the legend and status table and must be unique."
)]
pub labelled_targets: Vec<LabelledTargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(
self.primary_target_addr()
.expect("at least one target is required"),
self.duration,
)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn target_specs(&self) -> Result<Vec<TuiTargetSpec>, String> {
target_specs(&self.targets, &self.labelled_targets)
}
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedTuiTarget>, String> {
let specs = self.target_specs()?;
let config = self.to_client_config();
resolved_managed_targets(specs, &config)
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
fn primary_target_addr(&self) -> Option<&str> {
self.targets.first().map(String::as_str).or_else(|| {
self.labelled_targets
.first()
.map(|target| target.addr.as_str())
})
}
}
pub type TuiTargetSpec = TargetSpec;
pub type ResolvedTuiTarget = ResolvedTarget;
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct SharedOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
let mut argv = vec!["shared-only"];
argv.extend_from_slice(args);
SharedOnlyArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets, ["127.0.0.1:2112"]);
assert!(args.labelled_targets.is_empty());
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.to_client_config().duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.to_client_config().duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn multiple_positional_targets_parse() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].addr, "host-b:2112");
}
#[test]
fn repeated_labelled_targets_parse() {
let args = parse(&[
"--target",
"ams=ams.example.com:2112",
"--target",
"sg=sg.example.com:2112",
])
.unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].addr, "sg.example.com:2112");
}
#[test]
fn at_least_one_target_is_required() {
assert!(parse(&[]).is_err());
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap();
let err = args.target_specs().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_resolved_target_addresses_are_rejected() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
let err = args.resolved_managed_targets().unwrap_err();
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["--target", "missing-equals"]).is_err());
assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err());
assert!(parse(&["--target", "label="]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn shared_client_options_match_tui_config_mapping() {
let shared = [
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
];
let tui_args = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let tui = parse(&tui_args).unwrap().to_client_config();
let common = parse_shared(&shared).unwrap().common;
let shared = common.to_client_config("127.0.0.1:2112", Duration::from_secs(30));
assert_eq!(shared.server_addr, tui.server_addr);
assert_eq!(shared.duration, tui.duration);
assert_eq!(shared.interval, tui.interval);
assert_eq!(shared.length, tui.length);
assert_eq!(shared.received_stats, tui.received_stats);
assert_eq!(shared.stamp_at, tui.stamp_at);
assert_eq!(shared.clock, tui.clock);
assert_eq!(shared.dscp, tui.dscp);
assert_eq!(shared.hmac_key, tui.hmac_key);
assert_eq!(shared.server_fill, tui.server_fill);
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
}
#[test]
fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string();
assert!(help.contains("--target <LABEL=TARGET>"));
assert!(help.contains("--pacing <PACING>"));
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
+468
View File
@@ -0,0 +1,468 @@
use std::{
io,
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_client::{
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow,
};
use crate::{
cmd::tui::args::{ResolvedTuiTarget, TuiArgs},
shared::client::{is_shutdown_requested, ClientSession},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let targets = args
.resolved_managed_targets()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::with_target_labels(
TuiConfig::from_args(&args),
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)?;
if targets.len() > 1 {
return run_group_tui(
args,
targets,
&mut terminal,
&mut state,
&mut next_render,
shutdown_requested,
);
}
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
Ok(session) => session,
Err(err) => {
state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(err));
}
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
state.process_events(&events);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let mut interrupted = false;
// Keep this in lockstep with run_stream: send due probes, drain available
// replies, poll timeouts, sleep toward the next absolute send deadline,
// then perform the same final drain, timeout poll, and close sequence.
while session.should_continue(shutdown_requested) {
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
break;
}
let events = session.step(shutdown_requested)?;
state.process_events(&events);
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
render_if_due(&mut terminal, &state, &mut next_render, false)?;
wait_for_tui_activity(
session.next_send_deadline(),
&mut next_render,
&mut state,
&mut terminal,
shutdown_requested,
)?;
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if session.should_drain_final(interrupted) {
state.set_status(TuiStatus::Draining);
let mut drain_render = Instant::now();
session.drain_final(|events| {
state.process_events(events);
let _ = render_if_due(&mut terminal, &state, &mut drain_render, false);
})?;
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
let events = session.poll_timeouts()?;
state.process_events(&events);
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let events = session.close()?;
state.process_events(&events);
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(())
}
fn run_group_tui(
args: TuiArgs,
targets: Vec<ResolvedTuiTarget>,
terminal: &mut TuiTerminal,
state: &mut TuiState,
next_render: &mut Instant,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = args.to_client_config();
let managed_targets = targets
.iter()
.map(|target| target.managed.clone())
.collect::<Vec<_>>();
let expected_target_count = managed_targets.len();
if let Some(first) = managed_targets.first() {
config.server_addr = first.remote.to_string();
}
let group_config = ManagedClientGroupConfig {
client: config,
pacing: args.pacing.into(),
};
let (session, events) = ManagedClientGroup::start_with_subscription(
group_config,
managed_targets,
SubscriberConfig {
capacity: 16_384,
overflow: SubscriberOverflow::DropOldest,
},
)?;
let mut interrupted = false;
let mut terminal_targets = std::collections::HashSet::new();
let mut saw_target_event = false;
let mut last_event_at = Instant::now();
let exit = loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
session.stop();
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, next_render, true)?;
}
if state.quit_requested {
interrupted = true;
session.stop();
}
match events.try_recv() {
Ok(Some(target_event)) => {
saw_target_event = true;
last_event_at = Instant::now();
if is_terminal_target_event(&target_event.event) {
terminal_targets.insert(target_event.target.as_str().to_owned());
}
state.process_target_event(&target_event);
render_if_due(terminal, state, next_render, false)?;
}
Ok(None) => {
if interrupted {
break GroupLoopExit::Interrupted;
}
if terminal_targets.len() >= expected_target_count {
break GroupLoopExit::AllTargetsTerminal;
}
if should_join_group_after_idle(&args, saw_target_event, last_event_at) {
break GroupLoopExit::IdleGraceElapsed;
}
wait_for_tui_activity(None, next_render, state, terminal, shutdown_requested)?;
thread::sleep(IDLE_SLEEP);
}
Err(EventSubscriptionError::Disconnected) => {
break GroupLoopExit::SubscriptionDisconnected
}
}
};
if exit.should_stop_before_join() {
session.stop();
}
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(terminal, state, next_render, true)?;
}
state.set_status(TuiStatus::Closing);
render_if_due(terminal, state, next_render, true)?;
let outcome = session.join()?;
while let Ok(Some(target_event)) = events.try_recv() {
state.process_target_event(&target_event);
}
if outcome.end_reason == ManagedGroupEndReason::Cancelled && !interrupted {
return match exit {
GroupLoopExit::IdleGraceElapsed => {
Err("managed client group stayed idle before all targets completed".into())
}
GroupLoopExit::SubscriptionDisconnected => {
Err("managed client group event subscription disconnected before completion".into())
}
GroupLoopExit::Interrupted | GroupLoopExit::AllTargetsTerminal => {
Err("managed client group was cancelled".into())
}
};
}
state.set_status(TuiStatus::Complete);
render_if_due(terminal, state, next_render, true)?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupLoopExit {
Interrupted,
AllTargetsTerminal,
IdleGraceElapsed,
SubscriptionDisconnected,
}
impl GroupLoopExit {
fn should_stop_before_join(self) -> bool {
matches!(
self,
Self::Interrupted | Self::IdleGraceElapsed | Self::SubscriptionDisconnected
)
}
}
fn is_terminal_target_event(event: &ClientEvent) -> bool {
matches!(
event,
ClientEvent::SessionClosed { .. } | ClientEvent::NoTestCompleted { .. }
)
}
fn estimated_group_completion_grace(args: &TuiArgs) -> Duration {
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
open_timeout
.saturating_add(args.duration)
.saturating_add(GROUP_COMPLETION_GRACE)
}
fn should_join_group_after_idle(
args: &TuiArgs,
saw_target_event: bool,
last_event_at: Instant,
) -> bool {
if args.is_continuous() && saw_target_event {
return false;
}
last_event_at.elapsed() > estimated_group_completion_grace(args)
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
while event::poll(Duration::ZERO)? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.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) {
return Ok(());
}
terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL;
Ok(())
}
fn wait_for_tui_activity(
next_send_deadline: Option<Instant>,
next_render: &mut Instant,
state: &mut TuiState,
terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool,
) -> io::Result<()> {
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
if wait_for.is_zero() || !event::poll(wait_for)? {
return Ok(());
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, next_render, true)?;
}
Ok(())
}
fn tui_wait_duration(
next_send_deadline: Option<Instant>,
next_render: Instant,
paused: bool,
) -> Duration {
let now = Instant::now();
let send_wait = next_send_deadline
.map(|deadline| deadline.saturating_duration_since(now))
.unwrap_or(IDLE_SLEEP);
let render_wait = if paused {
send_wait
} else {
next_render.saturating_duration_since(now)
};
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> TuiArgs {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv).unwrap()
}
#[test]
fn continuous_multi_target_idle_does_not_stop_after_events() {
let args = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
let old_event_at = Instant::now() - estimated_group_completion_grace(&args) - IDLE_SLEEP;
assert!(!should_join_group_after_idle(&args, true, old_event_at));
}
#[test]
fn finite_or_unopened_group_can_leave_after_idle_grace() {
let finite = parse(&["--duration", "1s", "127.0.0.1:2112", "127.0.0.2:2112"]);
let finite_old_event_at =
Instant::now() - estimated_group_completion_grace(&finite) - IDLE_SLEEP;
assert!(should_join_group_after_idle(
&finite,
true,
finite_old_event_at
));
let continuous = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
let unopened_old_event_at =
Instant::now() - estimated_group_completion_grace(&continuous) - IDLE_SLEEP;
assert!(should_join_group_after_idle(
&continuous,
false,
unopened_old_event_at
));
}
#[test]
fn protective_group_exits_stop_before_joining() {
assert!(GroupLoopExit::Interrupted.should_stop_before_join());
assert!(GroupLoopExit::IdleGraceElapsed.should_stop_before_join());
assert!(GroupLoopExit::SubscriptionDisconnected.should_stop_before_join());
assert!(!GroupLoopExit::AllTargetsTerminal.should_stop_before_join());
}
}
File diff suppressed because it is too large. Load diff
+80
View File
@@ -0,0 +1,80 @@
use std::{
env,
ffi::OsString,
process::ExitCode,
sync::{atomic::AtomicBool, Arc},
};
use crate::{
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
signal::install_signal_handler,
};
pub fn main() -> ExitCode {
match run_from_env() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("irtt-rs: {err}");
ExitCode::FAILURE
}
}
}
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let (requested, argv) = match dispatch_from_argv(argv)? {
AppletDispatch::Run { applet, argv } => (applet, argv),
AppletDispatch::Help(help) => {
print!("{help}");
return Ok(());
}
};
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => Err("server applet is not available in this build".into()),
}
}
#[cfg(feature = "client")]
fn run_client_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested)
}
#[cfg(not(feature = "client"))]
fn run_client_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[cfg(feature = "tui")]
fn run_tui_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
crate::cmd::tui::run_tui(args, shutdown_requested)
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
File diff suppressed because it is too large. Load diff
-479
View File
@@ -1,479 +0,0 @@
use std::{
io::{self, Write},
process::ExitCode,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
};
use clap::Parser;
use irtt_cli::{
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
};
use irtt_client::{Client, ClientEvent, ClientTimestamp, OpenOutcome, RecvBudget};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
fn main() -> ExitCode {
let args = CliArgs::parse();
let shutdown_requested = Arc::new(AtomicBool::new(false));
if let Err(err) = install_signal_handler(Arc::clone(&shutdown_requested)) {
eprintln!("irtt-rs: failed to install signal handler: {err}");
return ExitCode::FAILURE;
}
match run(args, shutdown_requested.as_ref()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("irtt-rs: {err}");
ExitCode::FAILURE
}
}
}
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut output = EventOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_finite_summary: !continuous,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let mut client = Client::connect(args.to_client_config())?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let open = client.open(ClientTimestamp::now())?;
output.print_event(open_event(&open))?;
let mut interrupted = false;
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
let events = client.send_probe()?;
output.print_events(&events)?;
}
let events = client.recv_available(RECV_BUDGET)?;
output.print_events(&events)?;
let events = client.poll_timeouts(ClientTimestamp::now())?;
output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
sleep_until_next_send(client.next_send_deadline());
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if should_drain_final(continuous, interrupted) {
drain_final_replies(&mut client, &mut output)?;
}
let events = client.poll_timeouts(ClientTimestamp::now())?;
output.print_events(&events)?;
let events = client.close(ClientTimestamp::now())?;
output.print_events(&events)?;
output.print_summary()?;
output.out.flush()?;
Ok(())
}
fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
fn should_continue_run(
continuous: bool,
next_send_deadline: Option<Instant>,
shutdown_requested: &AtomicBool,
) -> bool {
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
fn drain_final_replies<W: Write>(
client: &mut Client,
output: &mut EventOutput<'_, W>,
) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
loop {
let mut printed = false;
let events = client.recv_available(RECV_BUDGET)?;
printed |= !events.is_empty();
output.print_events(&events)?;
let events = client.poll_timeouts(ClientTimestamp::now())?;
printed |= !events.is_empty();
output.print_events(&events)?;
if client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !printed {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
fn sleep_until_next_send(deadline: Option<Instant>) {
let sleep_for = match deadline {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
struct EventOutput<'a, W: Write> {
mode: irtt_cli::OutputMode,
human_options: HumanOutputOptions,
print_finite_summary: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
}
impl<W: Write> EventOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = irtt_cli::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
Some(format_human_event_with_options(
event,
Some(stats_update.into()),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_finite_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
irtt_cli::summary::format_summary_with_options(
&self.stats.summary(),
irtt_cli::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
#[cfg(test)]
mod shutdown_tests {
use super::*;
#[test]
fn shutdown_flag_stops_run_loop() {
let shutdown = AtomicBool::new(false);
assert!(should_continue_run(true, Some(Instant::now()), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_continue_run(true, Some(Instant::now()), &shutdown));
}
#[test]
fn shutdown_flag_suppresses_due_probe_send() {
let shutdown = AtomicBool::new(false);
let due = Instant::now() - Duration::from_millis(1);
assert!(should_send_probe(Some(due), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_send_probe(Some(due), &shutdown));
}
#[test]
fn interrupted_continuous_run_uses_final_drain_before_close() {
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
assert!(should_drain_final(false, false));
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use irtt_client::{PacketMeta, RttSample, SignedDuration};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::UNIX_EPOCH,
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(logical_seq: u64, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(logical_seq));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq: logical_seq as u32,
logical_seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: Duration::from_micros(rtt_us),
adjusted_signed: None,
effective_signed: SignedDuration {
ns: i128::from(rtt_us) * 1_000,
},
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
logical_seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: Duration::from_micros(1200),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_finite_summary: true,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.summary();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_output_does_not_print_finite_summary() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000 {
let sent_at = test_timestamp(Duration::from_micros(seq));
let received_at = test_timestamp(Duration::from_micros(seq + 1));
collector.process(&ClientEvent::EchoReply {
seq: seq as u32,
logical_seq: seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: Duration::from_micros(1),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.summary().rtt.primary.median_ns, None);
}
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
}
+223
View File
@@ -0,0 +1,223 @@
use std::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)]
pub struct CommonClientArgs {
/// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
/// UDP payload length.
#[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32,
/// HMAC key.
#[arg(long)]
pub hmac: Option<String>,
/// Clock mode to request.
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
pub clock: ClockArg,
/// Timestamp mode to request.
#[arg(
long = "tstamp",
visible_alias = "timestamps",
value_enum,
value_name = "MODE",
default_value_t = TimestampArg::Both
)]
pub tstamp: TimestampArg,
/// Received-stats mode to request.
#[arg(
long = "stats",
value_enum,
default_value_t = ReceivedStatsArg::Both,
help = "Server received-stats negotiation mode"
)]
pub stats: ReceivedStatsArg,
/// Server payload fill string to request, up to 32 bytes.
#[arg(
long = "sfill",
visible_alias = "server-fill",
value_name = "STRING",
value_parser = parse_server_fill
)]
pub server_fill: Option<String>,
/// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
pub dscp: u8,
/// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>,
/// Accept safe server restrictions during negotiation.
#[arg(long)]
pub loose: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg {
Wall,
Monotonic,
Both,
}
impl From<ClockArg> for Clock {
fn from(value: ClockArg) -> Self {
match value {
ClockArg::Wall => Self::Wall,
ClockArg::Monotonic => Self::Monotonic,
ClockArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampArg {
None,
Send,
Receive,
Both,
Midpoint,
}
impl From<TimestampArg> for StampAt {
fn from(value: TimestampArg) -> Self {
match value {
TimestampArg::None => Self::None,
TimestampArg::Send => Self::Send,
TimestampArg::Receive => Self::Receive,
TimestampArg::Both => Self::Both,
TimestampArg::Midpoint => Self::Midpoint,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReceivedStatsArg {
None,
Count,
Window,
Both,
}
impl From<ReceivedStatsArg> for ReceivedStats {
fn from(value: ReceivedStatsArg) -> Self {
match value {
ReceivedStatsArg::None => Self::None,
ReceivedStatsArg::Count => Self::Count,
ReceivedStatsArg::Window => Self::Window,
ReceivedStatsArg::Both => Self::Both,
}
}
}
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Ok(Duration::ZERO);
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
}
pub fn parse_length(input: &str) -> Result<u32, String> {
let length: u32 = input
.parse()
.map_err(|_| format!("invalid packet length {input:?}"))?;
if length > MAX_UDP_PAYLOAD_LENGTH {
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
}
Ok(length)
}
pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
}
let len = input.len();
if len > MAX_SERVER_FILL_BYTES {
return Err(format!(
"server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
));
}
Ok(input.to_owned())
}
pub fn parse_dscp(input: &str) -> Result<u8, String> {
let value: u8 = input
.parse()
.map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
if value > MAX_DSCP_CODEPOINT {
return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
}
Ok(value)
}
pub fn parse_ttl(input: &str) -> Result<u32, String> {
let value: u32 = input
.parse()
.map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
if value == 0 || value > MAX_TTL {
return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
}
Ok(value)
}
@@ -0,0 +1,48 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
impl CommonClientArgs {
pub fn to_client_config(&self, server: &str, duration: Duration) -> ClientConfig {
ClientConfig {
server_addr: server.to_owned(),
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
server_fill: self.server_fill.clone(),
negotiation_policy: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
socket_config: SocketConfig {
ttl: self.ttl,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
+15
View File
@@ -0,0 +1,15 @@
pub mod args;
pub mod config;
pub mod session;
pub mod targets;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
pub use targets::{
parse_labelled_target, resolved_managed_targets, target_specs, GroupPacingArg,
LabelledTargetArg, ResolvedTarget, TargetSpec,
};
@@ -0,0 +1,370 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
pub struct ClientSession {
client: Client,
continuous: bool,
}
impl ClientSession {
pub fn connect(
config: ClientConfig,
continuous: bool,
) -> Result<Self, irtt_client::ClientError> {
Ok(Self {
client: Client::connect(config)?,
continuous,
})
}
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
Ok(vec![open_event(&self.client.open()?).clone()])
}
pub fn step(
&mut self,
shutdown_requested: &AtomicBool,
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
let mut events = Vec::new();
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
events.extend(self.client.send_probe()?);
}
events.extend(self.client.recv_available(RECV_BUDGET)?);
if self.client.is_peer_closed() {
return Ok(events);
}
events.extend(self.client.poll_timeouts()?);
Ok(events)
}
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& !self.client.is_peer_closed()
&& (self.continuous || self.client.next_send_deadline().is_some())
}
pub fn should_drain_final(&self, interrupted: bool) -> bool {
should_drain_final(self.continuous, interrupted)
}
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
where
F: FnMut(&[ClientEvent]),
{
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
loop {
if self.client.is_peer_closed() {
break;
}
let mut received = false;
let events = self.client.recv_available(RECV_BUDGET)?;
received |= !events.is_empty();
on_events(&events);
if self.client.is_peer_closed() {
break;
}
let events = self.client.poll_timeouts()?;
received |= !events.is_empty();
on_events(&events);
if self.client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !received {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
if self.client.is_peer_closed() {
return Ok(vec![]);
}
self.client.poll_timeouts()
}
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
if self.client.is_peer_closed() {
return Ok(vec![]);
}
self.client.close()
}
pub fn next_send_deadline(&self) -> Option<Instant> {
self.client.next_send_deadline()
}
pub fn probe_timeout(&self) -> Duration {
self.client.probe_timeout()
}
pub fn sleep_until_next_send(&self) {
let sleep_for = match self.next_send_deadline() {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_proto::{
echo_packet_len,
flags::{self, FLAG_OPEN, FLAG_REPLY},
layout::PacketLayout,
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
};
use std::{
net::{SocketAddr, UdpSocket},
thread::JoinHandle,
};
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
struct FakeServer {
addr: SocketAddr,
done: JoinHandle<()>,
}
impl FakeServer {
fn join(self) {
self.done.join().unwrap();
}
}
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
#[test]
fn should_drain_final_for_finite_or_interrupted_runs() {
assert!(should_drain_final(false, false));
assert!(should_drain_final(false, true));
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
}
#[test]
fn peer_close_followed_by_session_cleanup_is_successful() {
let params = test_params(None, Duration::from_millis(10));
let server = start_peer_close_server(params);
let mut session = ClientSession::connect(config(server.addr, None), true).unwrap();
let shutdown_requested = AtomicBool::new(false);
assert!(matches!(
session.open().unwrap().as_slice(),
[ClientEvent::SessionStarted { .. }]
));
let events = session.step(&shutdown_requested).unwrap();
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
assert!(!session.should_continue(&shutdown_requested));
session.drain_final(|_| {}).unwrap();
assert!(session.poll_timeouts().unwrap().is_empty());
assert!(session.close().unwrap().is_empty());
server.join();
}
fn config(addr: SocketAddr, duration: Option<Duration>) -> ClientConfig {
ClientConfig {
server_addr: addr.to_string(),
duration,
interval: Duration::from_millis(10),
open_timeouts: vec![Duration::from_millis(200)],
probe_timeout: Duration::from_millis(20),
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..ClientConfig::default()
}
}
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
Params {
protocol_version: PROTOCOL_VERSION,
duration_ns: duration.map_or(0, test_duration_ns_i64),
interval_ns: test_duration_ns_i64(interval),
length: 0,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 0,
server_fill: None,
}
}
fn test_duration_ns_i64(duration: Duration) -> i64 {
i64::try_from(duration.as_nanos()).expect("test duration fits i64 nanoseconds")
}
fn start_peer_close_server(params: Params) -> FakeServer {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let addr = socket.local_addr().unwrap();
let done = thread::spawn(move || {
let (_, peer) = recv_request(&socket);
socket
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params), peer)
.unwrap();
let (packet, peer) = recv_request(&socket);
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
socket
.send_to(
&echo_reply_packet(
TOKEN,
seq,
&params,
&TimestampFields::default(),
FLAG_REPLY | flags::FLAG_CLOSE,
),
peer,
)
.unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
if let Some((packet, _)) = recv_request_timeout(&socket) {
panic!(
"session cleanup must not send any packet after peer close; flags={:?} len={}",
packet.get(3).copied(),
packet.len()
);
}
});
FakeServer { addr, done }
}
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
let mut buf = [0_u8; 2048];
let (size, peer) = socket.recv_from(&mut buf).unwrap();
(buf[..size].to_vec(), peer)
}
fn recv_request_timeout(socket: &UdpSocket) -> Option<(Vec<u8>, SocketAddr)> {
let mut buf = [0_u8; 2048];
socket
.recv_from(&mut buf)
.ok()
.map(|(size, peer)| (buf[..size].to_vec(), peer))
}
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&params.encode());
packet
}
fn echo_reply_packet(
token: u64,
seq: u32,
params: &Params,
timestamps: &TimestampFields,
flags: u8,
) -> Vec<u8> {
let layout = PacketLayout::echo(false, params);
let packet_len = echo_packet_len(false, params)
.expect("session test params must have a non-negative packet length");
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&seq.to_le_bytes());
if layout.recv_count {
packet.extend_from_slice(&42_u32.to_le_bytes());
}
if layout.recv_window {
packet.extend_from_slice(&0_u64.to_le_bytes());
}
if layout.recv_wall {
packet.extend_from_slice(&timestamps.recv_wall.unwrap_or(0).to_le_bytes());
}
if layout.recv_mono {
packet.extend_from_slice(&timestamps.recv_mono.unwrap_or(0).to_le_bytes());
}
if layout.midpoint_wall {
packet.extend_from_slice(&timestamps.midpoint_wall.unwrap_or(0).to_le_bytes());
}
if layout.midpoint_mono {
packet.extend_from_slice(&timestamps.midpoint_mono.unwrap_or(0).to_le_bytes());
}
if layout.send_wall {
packet.extend_from_slice(&timestamps.send_wall.unwrap_or(0).to_le_bytes());
}
if layout.send_mono {
packet.extend_from_slice(&timestamps.send_mono.unwrap_or(0).to_le_bytes());
}
packet.resize(packet_len, 0);
packet
}
}
@@ -0,0 +1,240 @@
use std::{collections::HashSet, net::ToSocketAddrs};
use clap::ValueEnum;
use irtt_client::{ClientConfig, ManagedGroupPacing, ManagedTargetConfig, TargetId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelledTargetArg {
pub label: String,
pub addr: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub addr: String,
}
#[derive(Debug, Clone)]
pub struct ResolvedTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
Burst,
}
impl From<GroupPacingArg> for ManagedGroupPacing {
fn from(value: GroupPacingArg) -> Self {
match value {
GroupPacingArg::Staggered => Self::Staggered,
GroupPacingArg::Burst => Self::Burst,
}
}
}
pub fn parse_labelled_target(input: &str) -> Result<LabelledTargetArg, String> {
let (label, addr) = input
.split_once('=')
.ok_or_else(|| "target must use LABEL=TARGET syntax".to_owned())?;
if label.is_empty() {
return Err("target label must not be empty".to_owned());
}
if addr.is_empty() {
return Err("target address must not be empty".to_owned());
}
Ok(LabelledTargetArg {
label: label.to_owned(),
addr: addr.to_owned(),
})
}
pub fn target_specs(
positional_targets: &[String],
labelled_targets: &[LabelledTargetArg],
) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::new();
let mut positional_counts = std::collections::HashMap::<&str, usize>::new();
for target in positional_targets {
let count = positional_counts.entry(target.as_str()).or_default();
*count += 1;
let label = if *count == 1 {
target.clone()
} else {
format!("{target}#{}", *count)
};
specs.push(TargetSpec {
label,
addr: target.clone(),
});
}
for target in labelled_targets {
specs.push(TargetSpec {
label: target.label.clone(),
addr: target.addr.clone(),
});
}
if specs.is_empty() {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label));
}
}
Ok(specs)
}
pub fn resolved_managed_targets(
specs: Vec<TargetSpec>,
config: &ClientConfig,
) -> Result<Vec<ResolvedTarget>, String> {
let mut remotes = HashSet::new();
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let remote = resolve_target(&spec.addr, config).map_err(|err| {
format!(
"failed to resolve target {} ({:?}): {err}",
spec.label, spec.addr
)
})?;
if !remotes.insert(remote) {
return Err(format!(
"duplicate resolved target address {remote} for label {}",
spec.label
));
}
targets.push(ResolvedTarget {
label: spec.label.clone(),
managed: ManagedTargetConfig {
id: TargetId::from(spec.label),
remote,
auth: None,
},
});
}
Ok(targets)
}
pub fn resolve_target(addr: &str, config: &ClientConfig) -> Result<std::net::SocketAddr, String> {
let normalized = normalize_target_addr(addr);
let mut addrs = normalized
.to_socket_addrs()
.map_err(|_| format!("failed to resolve address {normalized:?}"))?;
addrs
.find(|addr| {
(!config.socket_config.ipv4_only || addr.is_ipv4())
&& (!config.socket_config.ipv6_only || addr.is_ipv6())
})
.ok_or_else(|| format!("failed to resolve address {normalized:?}"))
}
pub fn normalize_target_addr(addr: &str) -> String {
const DEFAULT_PORT: u16 = 2112;
if addr.parse::<std::net::SocketAddr>().is_ok() {
return addr.to_owned();
}
if addr.starts_with('[') && addr.ends_with(']') {
return format!("{addr}:{DEFAULT_PORT}");
}
if addr.starts_with('[') {
return addr.to_owned();
}
if addr.parse::<std::net::Ipv6Addr>().is_ok() {
return format!("[{addr}]:{DEFAULT_PORT}");
}
if addr
.rsplit_once(':')
.is_some_and(|(_, port)| port.parse::<u16>().is_ok())
{
return addr.to_owned();
}
format!("{addr}:{DEFAULT_PORT}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_normalization_matches_single_target_forms() {
assert_eq!(normalize_target_addr("127.0.0.1"), "127.0.0.1:2112");
assert_eq!(normalize_target_addr("127.0.0.1:1234"), "127.0.0.1:1234");
assert_eq!(normalize_target_addr("example.com"), "example.com:2112");
assert_eq!(
normalize_target_addr("example.com:1234"),
"example.com:1234"
);
assert_eq!(normalize_target_addr("[::1]"), "[::1]:2112");
assert_eq!(normalize_target_addr("[::1]:1234"), "[::1]:1234");
assert_eq!(normalize_target_addr("::1"), "[::1]:2112");
}
#[test]
fn target_resolution_respects_family_filters() {
let config = ClientConfig::default();
assert_eq!(
resolve_target("127.0.0.1", &config).unwrap(),
"127.0.0.1:2112".parse().unwrap()
);
assert_eq!(
resolve_target("[::1]", &config).unwrap(),
"[::1]:2112".parse().unwrap()
);
assert_eq!(
resolve_target("::1", &config).unwrap(),
"[::1]:2112".parse().unwrap()
);
let mut ipv4_only = ClientConfig::default();
ipv4_only.socket_config.ipv4_only = true;
assert!(resolve_target("127.0.0.1", &ipv4_only).unwrap().is_ipv4());
assert!(resolve_target("::1", &ipv4_only).is_err());
let mut ipv6_only = ClientConfig::default();
ipv6_only.socket_config.ipv6_only = true;
assert!(resolve_target("::1", &ipv6_only).unwrap().is_ipv6());
assert!(resolve_target("127.0.0.1", &ipv6_only).is_err());
}
#[test]
fn target_specs_suffix_repeated_positionals_and_reject_duplicate_labels() {
let positionals = vec!["host-a:2112".to_owned(), "host-a:2112".to_owned()];
let specs = target_specs(&positionals, &[]).unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
let labels = vec![LabelledTargetArg {
label: "host-a:2112".to_owned(),
addr: "host-b:2112".to_owned(),
}];
let err = target_specs(&positionals[..1], &labels).unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_resolved_target_addresses_are_rejected() {
let specs = vec![
TargetSpec {
label: "a".to_owned(),
addr: "127.0.0.1:2112".to_owned(),
},
TargetSpec {
label: "b".to_owned(),
addr: "127.0.0.1".to_owned(),
},
];
let err = resolved_managed_targets(specs, &ClientConfig::default()).unwrap_err();
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(feature = "client-runtime")]
pub mod client;
+10
View File
@@ -0,0 +1,10 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
+155
View File
@@ -0,0 +1,155 @@
use std::process::{Command, Output};
fn irtt_rs() -> Command {
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
}
#[cfg(unix)]
fn irtt_rs_with_arg0(arg0: &str) -> Command {
use std::os::unix::process::CommandExt;
let mut command = irtt_rs();
command.arg0(arg0);
command
}
fn output_text(output: &Output) -> String {
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&output.stdout));
text.push_str(&String::from_utf8_lossy(&output.stderr));
text
}
#[test]
fn canonical_help_shows_dispatcher_help() {
let output = irtt_rs().arg("--help").output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[test]
fn canonical_without_applet_errors() {
let output = irtt_rs().output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("choose an applet"), "{text}");
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[cfg(feature = "client")]
#[test]
fn canonical_client_subcommand_dispatches_to_client() {
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(not(feature = "client"))]
#[test]
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(unix)]
#[cfg(feature = "client")]
#[test]
fn client_applet_name_dispatches_to_client() {
let output = irtt_rs_with_arg0("irtt-cli")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(all(unix, not(feature = "client")))]
#[test]
fn client_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-cli")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(all(unix, feature = "tui"))]
#[test]
fn tui_applet_name_dispatches_to_tui_when_enabled() {
let output = irtt_rs_with_arg0("irtt-tui")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible TUI client"));
assert!(text.contains("--duration <DURATION>"));
}
#[cfg(all(unix, not(feature = "tui")))]
#[test]
fn tui_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-tui")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("TUI applet is not available"), "{text}");
}
#[cfg(unix)]
#[test]
fn server_applet_name_reports_unavailable() {
let output = irtt_rs_with_arg0("irtt-server")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("server applet is not available"), "{text}");
}
#[cfg(unix)]
#[test]
fn unknown_irtt_applet_name_errors() {
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
assert!(text.contains("irtt-cli"));
assert!(text.contains("irtt-tui"));
assert!(text.contains("irtt-server"));
}
@@ -0,0 +1,284 @@
#![cfg(feature = "client")]
use std::{
net::{SocketAddr, UdpSocket},
process::Command,
thread,
thread::JoinHandle,
time::Duration,
};
use irtt_proto::{
echo_packet_len,
flags::{FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY},
layout::PacketLayout,
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
};
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
struct FakeServer {
addr: SocketAddr,
done: JoinHandle<()>,
}
impl FakeServer {
fn join(self) {
self.done.join().unwrap();
}
}
#[test]
fn list_columns_succeeds_without_target() {
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
.arg("--list-columns")
.output()
.unwrap();
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Available event columns:"), "{stdout}");
assert!(stdout.contains("target"), "{stdout}");
}
#[test]
fn single_target_default_table_omits_target_and_accepts_pacing() {
let server = start_echo_server(test_params(
Some(Duration::from_millis(30)),
Duration::from_millis(10),
));
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
.args([
"--duration",
"30ms",
"--interval",
"10ms",
"--pacing",
"burst",
"--header",
"always",
&server.addr.to_string(),
])
.output()
.unwrap();
server.join();
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let header = stdout.lines().next().unwrap_or_default();
assert!(!header.split_whitespace().any(|column| column == "target"));
}
#[test]
fn single_target_custom_target_column_renders_positional_label() {
let server = start_echo_server(test_params(
Some(Duration::from_millis(30)),
Duration::from_millis(10),
));
let target = server.addr.to_string();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
.args([
"--duration",
"30ms",
"--interval",
"10ms",
"--format",
"csv",
"--columns",
"target,seq",
"--header",
"never",
&target,
])
.output()
.unwrap();
server.join();
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout
.lines()
.any(|line| line.starts_with(&format!("{target},"))),
"{stdout}"
);
}
#[test]
fn multi_target_csv_emits_rows_for_both_labels() {
let params = test_params(Some(Duration::from_millis(40)), Duration::from_millis(10));
let a = start_echo_server(params.clone());
let b = start_echo_server(params);
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
.args([
"--duration",
"40ms",
"--interval",
"10ms",
"--format",
"csv",
"--columns",
"target,seq,effective_rtt_us",
"--header",
"never",
"--target",
&format!("a={}", a.addr),
"--target",
&format!("b={}", b.addr),
])
.output()
.unwrap();
a.join();
b.join();
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.lines().any(|line| line.starts_with("a,")),
"{stdout}"
);
assert!(
stdout.lines().any(|line| line.starts_with("b,")),
"{stdout}"
);
}
fn start_echo_server(params: Params) -> FakeServer {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let addr = socket.local_addr().unwrap();
let done = thread::spawn(move || {
let (_, peer) = recv_request(&socket);
socket
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params), peer)
.unwrap();
loop {
let (packet, peer) = recv_request(&socket);
let flags = packet[3];
if flags & FLAG_CLOSE != 0 {
break;
}
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
socket
.send_to(
&echo_reply_packet(
TOKEN,
seq,
&params,
&TimestampFields::default(),
FLAG_REPLY,
),
peer,
)
.unwrap();
}
});
FakeServer { addr, done }
}
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
let mut buf = [0_u8; 2048];
let (size, peer) = socket.recv_from(&mut buf).unwrap();
(buf[..size].to_vec(), peer)
}
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&params.encode());
packet
}
fn echo_reply_packet(
token: u64,
seq: u32,
params: &Params,
timestamps: &TimestampFields,
flags: u8,
) -> Vec<u8> {
let layout = PacketLayout::echo(false, params);
let packet_len = echo_packet_len(false, params).unwrap();
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&seq.to_le_bytes());
if layout.recv_count {
packet.extend_from_slice(&42_u32.to_le_bytes());
}
if layout.recv_window {
packet.extend_from_slice(&0_u64.to_le_bytes());
}
if layout.recv_wall {
packet.extend_from_slice(&timestamps.recv_wall.unwrap_or(0).to_le_bytes());
}
if layout.recv_mono {
packet.extend_from_slice(&timestamps.recv_mono.unwrap_or(0).to_le_bytes());
}
if layout.midpoint_wall {
packet.extend_from_slice(&timestamps.midpoint_wall.unwrap_or(0).to_le_bytes());
}
if layout.midpoint_mono {
packet.extend_from_slice(&timestamps.midpoint_mono.unwrap_or(0).to_le_bytes());
}
if layout.send_wall {
packet.extend_from_slice(&timestamps.send_wall.unwrap_or(0).to_le_bytes());
}
if layout.send_mono {
packet.extend_from_slice(&timestamps.send_mono.unwrap_or(0).to_le_bytes());
}
packet.resize(packet_len, 0);
packet
}
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
Params {
protocol_version: PROTOCOL_VERSION,
duration_ns: duration.map_or(0, duration_ns_i64),
interval_ns: duration_ns_i64(interval),
length: 0,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 0,
server_fill: None,
}
}
fn duration_ns_i64(duration: Duration) -> i64 {
i64::try_from(duration.as_nanos()).unwrap()
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-client"
version = "0.1.1"
version = "0.4.0"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
@@ -14,7 +14,7 @@ categories = ["network-programming"]
[dependencies]
irtt-proto.workspace = true
libc = { version = "0.2.186", optional = true }
socket2 = { version = "0.6.3", features = ["all"] }
socket2 = { version = "0.6.4", features = ["all"] }
thiserror = "2.0.18"
[features]
File diff suppressed because it is too large. Load diff
+9 -37
View File
@@ -11,12 +11,9 @@ fn open_fails_after_close() {
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyClosed)
));
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
server.join();
}
@@ -31,8 +28,8 @@ fn close_sends_one_close_packet_with_negotiated_token() {
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let events = client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
let events = client.close().unwrap();
assert_eq!(events.len(), 1);
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
@@ -62,8 +59,8 @@ fn send_probe_fails_after_close() {
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
@@ -71,31 +68,6 @@ fn send_probe_fails_after_close() {
server.join();
}
#[test]
fn close_clears_timed_out_metadata() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
client.poll_timeouts(ClientTimestamp::now()).unwrap();
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
client.close(ClientTimestamp::now()).unwrap();
assert!(client.session.is_none());
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
@@ -132,7 +104,7 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
@@ -170,6 +142,6 @@ fn normal_echo_reply_does_not_close_session() {
assert!(client.next_send_deadline().is_some());
assert!(client.send_probe().is_ok());
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
server.join();
}
+80 -98
View File
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
}
#[test]
fn params_from_config_accepts_udp_payload_length_boundary_values() {
fn params_from_config_accepts_boundary_values() {
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
let config = ClientConfig {
length,
@@ -62,18 +62,12 @@ fn params_from_config_accepts_udp_payload_length_boundary_values() {
i64::from(length)
);
}
}
#[test]
fn params_from_config_rejects_oversized_udp_payload_length() {
let config = ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
dscp: 63,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
));
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
@@ -86,97 +80,85 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
}
#[test]
fn params_from_config_rejects_zero_finite_duration() {
let config = ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration must be greater than zero; use None for continuous mode"
));
}
#[test]
fn params_from_config_rejects_zero_interval() {
let config = ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
));
}
#[test]
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
fn params_from_config_rejects_invalid_values() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let config = ClientConfig {
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration is too large to encode as nanoseconds"
));
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let cases = [
(
"oversized UDP payload",
ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..ClientConfig::default()
},
"packet length",
),
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
},
"duration must be greater than zero; use None for continuous mode",
),
(
"zero interval",
ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
},
"interval must be greater than zero",
),
(
"duration nanosecond overflow",
ClientConfig {
duration: Some(too_large),
..ClientConfig::default()
},
"duration is too large to encode as nanoseconds",
),
(
"interval nanosecond overflow",
ClientConfig {
interval: too_large,
..ClientConfig::default()
},
"interval is too large to encode as nanoseconds",
),
(
"invalid DSCP codepoint",
ClientConfig {
dscp: 64,
..ClientConfig::default()
},
"dscp",
),
(
"empty server fill",
ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
(
"oversized server fill",
ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
];
let config = ClientConfig {
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "interval is too large to encode as nanoseconds"
));
}
#[test]
fn params_from_config_accepts_max_dscp_codepoint() {
let config = ClientConfig {
dscp: 63,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
fn params_from_config_rejects_invalid_dscp_codepoint() {
let config = ClientConfig {
dscp: 64,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_empty_server_fill() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_oversized_server_fill() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
for (name, config, expected_reason) in cases {
assert!(
matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
),
"{name} should fail with InvalidConfig containing {expected_reason:?}"
);
}
}
#[test]
+10 -3
View File
@@ -26,13 +26,13 @@ fn normal_open_applies_negotiated_dscp_after_open_and_close_clears_it() {
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
0
);
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
184
);
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
@@ -58,8 +58,15 @@ fn normal_open_uses_negotiated_dscp_not_requested_dscp() {
config.negotiation_policy = NegotiationPolicy::Loose;
let mut client = Client::connect(config).unwrap();
let negotiated = assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params.dscp, 0);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
+7 -7
View File
@@ -14,7 +14,7 @@ fn hmac_open_success() {
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
server.join();
}
@@ -32,7 +32,7 @@ fn hmac_open_rejects_missing_hmac() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(
irtt_proto::ProtoError::HmacPresenceMismatch
))
@@ -55,7 +55,7 @@ fn hmac_open_rejects_bad_hmac() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::BadHmac))
));
server.join();
@@ -76,8 +76,8 @@ fn hmac_close_packet_includes_valid_hmac() {
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
@@ -125,7 +125,7 @@ fn bad_hmac_reply_is_dropped() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
@@ -174,7 +174,7 @@ fn hmac_echo_request_reply_works() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
+2 -2
View File
@@ -1,8 +1,8 @@
use super::*;
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
use irtt_proto::{
echo_packet_len, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_hmac, Clock,
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_hmac, Clock, ReceivedStats,
StampAt, HMAC_SIZE, MAGIC,
};
use std::{thread, time::SystemTime};
@@ -1,32 +1,72 @@
use super::*;
use crate::NegotiationRestriction;
fn assert_negotiates(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> NegotiatedParams {
negotiate_params(requested, returned.clone(), policy)
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
}
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
match negotiate_params(requested, returned.clone(), policy) {
Err(ClientError::NegotiationRejected { reason }) => reason,
other => panic!("expected negotiation rejection, got {other:?}"),
}
}
#[test]
fn strict_negotiation_accepts_identical_params() {
let config = ClientConfig::default();
let params = params_from_config(&config).unwrap();
assert!(validate_negotiated_params(&params, &params, NegotiationPolicy::Strict).is_ok());
let negotiated = assert_negotiates(&params, &params, NegotiationPolicy::Strict);
assert_eq!(negotiated.params, params);
assert!(negotiated.restrictions.is_empty());
}
#[test]
fn strict_negotiation_rejects_changed_params() {
let config = ClientConfig::default();
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
Err(ClientError::NegotiationRejected { .. })
));
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
}
.message()
);
}
#[test]
fn loose_negotiation_accepts_server_restricted_params() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
let mut requested = params_from_config(&config).unwrap();
requested.length = 128;
let mut returned = requested.clone();
returned.duration_ns /= 2;
returned.length = 0;
assert!(validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose).is_ok());
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
},
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
},
]
);
}
#[test]
@@ -37,11 +77,10 @@ fn loose_negotiation_rejects_non_positive_returned_interval() {
for interval_ns in [0, -1] {
let mut returned = requested.clone();
returned.interval_ns = interval_ns;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "interval must be positive"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"interval must be positive"
);
}
}
@@ -51,21 +90,28 @@ fn loose_negotiation_accepts_documented_duration_combinations() {
let requested_finite = params_from_config(&finite_config).unwrap();
let returned_same_finite = requested_finite.clone();
assert!(validate_negotiated_params(
assert!(assert_negotiates(
&requested_finite,
&returned_same_finite,
NegotiationPolicy::Loose
)
.is_ok());
.restrictions
.is_empty());
let mut returned_shorter_finite = requested_finite.clone();
returned_shorter_finite.duration_ns /= 2;
assert!(validate_negotiated_params(
&requested_finite,
&returned_shorter_finite,
NegotiationPolicy::Loose
)
.is_ok());
assert_eq!(
assert_negotiates(
&requested_finite,
&returned_shorter_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_finite.duration_ns,
negotiated_ns: returned_shorter_finite.duration_ns,
}]
);
let continuous_config = ClientConfig {
duration: None,
@@ -74,21 +120,28 @@ fn loose_negotiation_accepts_documented_duration_combinations() {
let requested_continuous = params_from_config(&continuous_config).unwrap();
let returned_continuous = requested_continuous.clone();
assert!(validate_negotiated_params(
assert!(assert_negotiates(
&requested_continuous,
&returned_continuous,
NegotiationPolicy::Loose
)
.is_ok());
.restrictions
.is_empty());
let mut returned_finite = requested_continuous.clone();
returned_finite.duration_ns = 1_000_000_000;
assert!(validate_negotiated_params(
&requested_continuous,
&returned_finite,
NegotiationPolicy::Loose
)
.is_ok());
assert_eq!(
assert_negotiates(
&requested_continuous,
&returned_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_continuous.duration_ns,
negotiated_ns: returned_finite.duration_ns,
}]
);
}
#[test]
@@ -98,11 +151,10 @@ fn loose_negotiation_rejects_finite_request_returned_continuous() {
let mut returned = requested.clone();
returned.duration_ns = 0;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "server returned continuous duration for finite request"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned continuous duration for finite request"
);
}
#[test]
@@ -111,10 +163,10 @@ fn loose_negotiation_rejects_negative_returned_length() {
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason }) if reason == "length must be non-negative"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"length must be non-negative"
);
}
#[test]
@@ -123,10 +175,10 @@ fn strict_negotiation_rejects_negative_returned_length() {
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
Err(ClientError::NegotiationRejected { reason }) if reason == "length must be non-negative"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"length must be non-negative"
);
}
#[test]
@@ -137,10 +189,80 @@ fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
for dscp in [-1, 64] {
let mut returned = requested.clone();
returned.dscp = dscp;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "dscp must be in range 0..=63"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"dscp must be in range 0..=63"
);
}
}
#[test]
fn loose_negotiation_records_dscp_disabled_by_server() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
}
#[test]
fn strict_negotiation_rejects_dscp_disabled_by_server_as_specific_restriction() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}
.message()
);
}
#[test]
fn negotiation_rejects_unsupported_dscp_changes() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 8;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"server returned unsupported DSCP change"
);
let zero_config = ClientConfig::default();
let zero_requested = params_from_config(&zero_config).unwrap();
let mut returned = zero_requested.clone();
returned.dscp = 46;
assert_eq!(
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
}
+17 -30
View File
@@ -1,23 +1,5 @@
use super::*;
#[test]
fn no_test_open_close_succeeds_on_open_reply_close() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
assert!(matches!(
client.close(ClientTimestamp::now()),
Err(ClientError::NotOpen)
));
server.join();
}
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -26,7 +8,7 @@ fn no_test_success_validates_params() {
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(ClientTimestamp::now()).unwrap());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
server.join();
}
@@ -40,7 +22,7 @@ fn no_test_rejects_non_close_open_reply() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::UnexpectedNoTestReply)
));
server.join();
@@ -55,7 +37,7 @@ fn no_test_rejects_non_zero_token_with_close_reply() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
));
server.join();
@@ -71,7 +53,7 @@ fn no_test_strict_negotiation_rejects_changed_params() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
@@ -82,13 +64,21 @@ 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 mut params = params_from_config(&config).unwrap();
let requested = params_from_config(&config).unwrap();
let mut params = requested.clone();
params.duration_ns /= 2;
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: params.duration_ns,
}]
);
server.join();
}
@@ -100,7 +90,7 @@ fn send_probe_fails_after_no_test_completed() {
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert_no_test_completed(client.open().unwrap());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyCompleted)
@@ -116,10 +106,7 @@ fn open_fails_after_no_test_completed() {
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyCompleted)
));
assert_no_test_completed(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
server.join();
}
+7 -52
View File
@@ -1,16 +1,5 @@
use super::*;
#[test]
fn address_resolution_connects_to_local_fake_server() {
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let client = Client::connect(default_test_config(server.addr)).unwrap();
client.socket.send(b"ping").unwrap();
assert_eq!(server.rx.recv().unwrap(), b"ping");
server.join();
}
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -18,9 +7,8 @@ fn successful_open_handshake() {
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let negotiated = assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
server.join();
}
@@ -30,11 +18,8 @@ fn open_fails_when_already_open() {
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyOpen)
));
assert_open_started(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
server.join();
}
@@ -53,7 +38,7 @@ fn open_retries_after_first_timeout() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_open_started(outcome);
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
@@ -67,38 +52,11 @@ fn open_timeout_after_all_timeouts() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
}
#[test]
fn open_restores_configured_read_timeout_after_timeout() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(450)),
..crate::SocketConfig::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let start = std::time::Instant::now();
let mut buf = [0_u8; 1];
assert!(client.socket.recv(&mut buf).is_err());
assert!(start.elapsed() >= Duration::from_millis(350));
server.join();
}
#[test]
fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -108,7 +66,7 @@ fn protocol_version_mismatch_fails() {
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
));
server.join();
@@ -124,9 +82,6 @@ fn server_rejection_fails_in_normal_mode() {
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::ServerRejected)
));
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
server.join();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
use super::*;
#[test]
fn recv_available_drains_burst_replies() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let events = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(events.len(), 3);
for (seq, event) in events.iter().enumerate() {
assert!(matches!(
event,
ClientEvent::EchoReply {
seq: actual_seq,
..
} if *actual_seq == seq as u32
));
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_available_respects_packet_budget() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let first = client
.recv_available(RecvBudget { max_packets: 1 })
.unwrap();
assert_eq!(first.len(), 1);
assert!(matches!(&first[0], ClientEvent::EchoReply { seq: 0, .. }));
let second = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(second.len(), 1);
assert!(matches!(&second[0], ClientEvent::EchoReply { seq: 1, .. }));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,29 @@
use super::*;
use support::echo_packet_len;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
fn assert_packet_meta_unavailable(packet_meta: &crate::event::PacketMeta) {
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn metadata_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping metadata assertion because kernel did not provide traffic class metadata");
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping kernel timestamp assertion because kernel did not provide SCM_TIMESTAMPNS");
}
mod burst_recv;
mod packet_length;
mod probe_schedule;
mod reply_classification;
mod reply_decode;
mod reply_metadata;
mod timeouts;
mod timing_math;
@@ -0,0 +1,190 @@
use super::*;
#[test]
fn minimal_negotiated_layout_works() {
let params = Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Both,
..Params::default()
};
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
if let ClientEvent::EchoReply {
received_stats,
server_timing,
..
} = &events[0]
{
assert!(received_stats.is_none());
assert!(server_timing.is_none());
} else {
panic!("expected EchoReply");
}
server.join();
}
// ---------- Regression tests ----------
#[test]
fn short_echo_reply_does_not_emit_echo_reply() {
let params = Params {
length: 64,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.truncate(echo_packet_len(false, &params) - 1);
socket.send_to(&reply, peer).unwrap();
}
});
let config = ClientConfig {
length: 64,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn overlong_datagram_detection_uses_extra_receive_byte() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.push(0);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn exact_length_echo_reply_still_emits_echo_reply() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoReply { bytes, .. }] if *bytes == echo_packet_len(false, &params)
));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,121 @@
use super::*;
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now0).is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now1).is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client.send_probe_at(now2).unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
@@ -0,0 +1,113 @@
use super::*;
#[test]
fn duplicate_reply_emits_duplicate_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
thread::sleep(Duration::from_millis(10));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events1 = client.recv_once().unwrap();
assert_eq!(events1.len(), 1);
assert!(matches!(&events1[0], ClientEvent::EchoReply { .. }));
thread::sleep(Duration::from_millis(30));
let events2 = client.recv_once().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(
&events2[0],
ClientEvent::DuplicateReply { seq: 0, .. }
));
server.join();
}
#[test]
fn out_of_order_reply_emits_late_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
seqs.push(seq);
}
}
let ts = TimestampFields::default();
let reply1 = echo_reply_packet(TOKEN, seqs[1], &params, &ts, None);
socket.send_to(&reply1, peer).unwrap();
thread::sleep(Duration::from_millis(10));
let reply0 = echo_reply_packet(TOKEN, seqs[0], &params, &ts, None);
socket.send_to(&reply0, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let ev1 = client.recv_once().unwrap();
assert_eq!(ev1.len(), 1);
assert!(matches!(&ev1[0], ClientEvent::EchoReply { seq: 1, .. }));
thread::sleep(Duration::from_millis(30));
let ev2 = client.recv_once().unwrap();
assert_eq!(ev2.len(), 1);
match &ev2[0] {
ClientEvent::LateReply {
seq,
highest_seen,
rtt,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*highest_seen, 1);
assert!(rtt.is_some());
}
other => panic!("expected LateReply, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,140 @@
use super::*;
#[test]
fn recv_once_returns_empty_on_timeout() {
let params = default_params();
let server = open_success_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.recv_once().unwrap();
assert!(events.is_empty());
server.join();
}
#[test]
fn recv_once_decodes_echo_reply_and_emits_event() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
seq,
rtt,
received_stats,
server_timing,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*bytes, echo_packet_len(false, &params));
assert!(rtt.raw > Duration::ZERO);
assert_eq!(
rtt.effective,
rtt.adjusted
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
);
assert!(received_stats.is_some());
let stats = received_stats.as_ref().unwrap();
assert_eq!(stats.count, Some(42));
assert_eq!(stats.window, Some(0x07));
assert!(server_timing.is_some());
let st = server_timing.as_ref().unwrap();
assert!(st.processing.is_some());
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
{
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
{
assert_eq!(
packet_meta.dscp,
packet_meta.traffic_class.map(|tc| tc >> 2)
);
assert_eq!(
packet_meta.ecn,
packet_meta.traffic_class.map(|tc| tc & 0b11)
);
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
let params = default_params();
let server_params = params.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
socket.send_to(&reply, peer).unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let (packet, _) = recv_request(&socket, &tx);
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
}
let mut longer = echo_reply_packet(
TOKEN,
seqs[0],
&server_params,
&TimestampFields::default(),
None,
);
longer.extend_from_slice(b"trailing bytes");
socket.send_to(&longer, peer).unwrap();
let shorter = echo_reply_packet(
TOKEN,
seqs[1],
&server_params,
&TimestampFields::default(),
None,
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = client.recv_once().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { seq, bytes, .. } => {
assert_eq!(*seq, 1);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,86 @@
#[cfg(all(target_os = "linux", feature = "ancillary"))]
use super::*;
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
crate::socket_options::apply_dscp_to_socket(&socket, peer, 46).unwrap();
let reply_packet =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(traffic_class) = packet_meta.traffic_class else {
metadata_unavailable_skip(
"echo_reply_metadata_propagates_observed_dscp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert_eq!(traffic_class, 184);
assert_eq!(packet_meta.dscp, Some(46));
assert_eq!(packet_meta.ecn, Some(0));
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(timestamp) = packet_meta.kernel_rx_timestamp else {
kernel_rx_timestamp_unavailable_skip(
"echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert!(timestamp.duration_since(SystemTime::UNIX_EPOCH).is_ok());
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
@@ -0,0 +1,161 @@
use super::*;
#[test]
fn poll_timeouts_emits_echo_loss() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(100),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
let no_loss = client.poll_timeouts().unwrap();
assert!(no_loss.is_empty());
thread::sleep(Duration::from_millis(150));
let events = client.poll_timeouts().unwrap();
assert_eq!(events.len(), 2);
for event in &events {
assert!(matches!(event, ClientEvent::EchoLoss { .. }));
}
server.join();
}
#[test]
fn late_reply_after_timeout_preserves_measurement_metadata() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
thread::sleep(Duration::from_millis(90));
let ts = TimestampFields {
recv_wall: Some(1_000_000_000),
recv_mono: Some(100_000),
send_wall: Some(1_000_100_000),
send_mono: Some(200_000),
..Default::default()
};
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
let losses = client.poll_timeouts().unwrap();
assert!(matches!(&losses[0], ClientEvent::EchoLoss { seq: 0, .. }));
let late = client.recv_once().unwrap();
match &late[0] {
ClientEvent::LateReply {
seq,
sent_at,
rtt,
server_timing,
one_way,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert!(sent_at.is_some());
assert!(rtt.is_some());
assert!(server_timing.is_some());
assert!(one_way.is_some());
assert_eq!(*bytes, echo_packet_len(false, &default_params()));
let _ = packet_meta;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
assert_packet_meta_unavailable(packet_meta);
}
other => panic!("expected stats-eligible LateReply, got {other:?}"),
}
let duplicate = client.recv_once().unwrap();
assert!(matches!(
&duplicate[0],
ClientEvent::DuplicateReply {
seq: 0,
bytes,
..
} if *bytes == echo_packet_len(false, &default_params())
));
client.close().unwrap();
server.join();
}
#[test]
fn pending_full_does_not_send_packet() {
let params = Params {
duration_ns: 60_000_000_000,
..default_params()
};
let server = silent_open_server(params);
let config = ClientConfig {
duration: Some(Duration::from_secs(60)),
max_pending_probes: 2,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let before_count: Vec<_> = server.rx.try_iter().collect();
let echo_before: Vec<_> = before_count
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(echo_before.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
thread::sleep(Duration::from_millis(30));
let after: Vec<_> = server.rx.try_iter().collect();
let echo_after: Vec<_> = after
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(
echo_after.len(),
0,
"no packet should be sent when pending is full"
);
client.close().unwrap();
server.join();
}
@@ -0,0 +1,157 @@
use super::*;
#[test]
fn server_processing_greater_than_raw_does_not_underflow() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(1),
wall: SystemTime::now(),
},
&TimestampFields {
recv_mono: Some(0),
send_mono: Some(1_000_000_000),
..Default::default()
},
);
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
}
#[test]
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(42),
wall: SystemTime::now(),
},
&TimestampFields::default(),
);
assert_eq!(rtt.adjusted, None);
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
}
#[test]
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
let overflow = max + Duration::from_nanos(1);
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
assert_eq!(unix_epoch_ns_i64(overflow), None);
}
#[test]
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let overflow_wall =
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: overflow_wall,
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: overflow_wall + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(i64::MAX),
send_wall: Some(i64::MAX),
..Default::default()
};
assert_eq!(compute_one_way(&sent_at, &received_at, &ts), None);
}
#[test]
fn compute_one_way_returns_available_direction_samples() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_client_to_server_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 - 5_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(-5_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_server_to_client_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 45_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(-5_000_000))
);
}
@@ -1,10 +1,4 @@
use super::*;
use std::time::Instant;
use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
session::ActiveSession,
};
#[test]
fn sequence_ordering_handles_normal_and_wrapped_values() {
@@ -24,22 +18,10 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
#[test]
fn highest_received_seq_updates_across_wrap() {
let mut session = ActiveSession {
next_wire_seq: 0,
next_logical_seq: 0,
highest_received_seq: Some(u32::MAX),
packets_sent: 0,
start_mono: Instant::now(),
end_mono: None,
next_send_at: Instant::now(),
pending: PendingMap::new(8),
timed_out: TimedOutMap::new(8),
completed: CompletedSet::new(8),
sending_done: false,
};
let mut highest = Some(u32::MAX);
update_highest_received(&mut session, 0);
assert_eq!(session.highest_received_seq, Some(0));
update_highest_received(&mut session, u32::MAX);
assert_eq!(session.highest_received_seq, Some(0));
update_highest_received(&mut highest, 0);
assert_eq!(highest, Some(0));
update_highest_received(&mut highest, u32::MAX);
assert_eq!(highest, Some(0));
}
@@ -1,7 +1,8 @@
use super::*;
use irtt_proto::{
compute_hmac_in_place, echo_packet_len, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY,
layout::PacketLayout, Clock, ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
compute_hmac_in_place, echo_packet_len as checked_echo_packet_len, flags::FLAG_HMAC,
flags::FLAG_OPEN, flags::FLAG_REPLY, layout::PacketLayout, Clock, ReceivedStats, StampAt,
HMAC_SIZE, MAGIC,
};
use std::{
net::UdpSocket,
@@ -74,6 +75,11 @@ pub(super) fn open_reply(
packet
}
pub(crate) fn echo_packet_len(hmac: bool, params: &Params) -> usize {
checked_echo_packet_len(hmac, params)
.expect("test params must have a non-negative packet length")
}
pub(super) fn echo_reply_packet(
token: u64,
seq: u32,
@@ -283,7 +289,7 @@ pub(super) fn open_client_with_echo_server(params: &Params) -> (Client, FakeServ
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
(client, server)
}
+2 -2
View File
@@ -29,10 +29,10 @@ fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
client.open(ClientTimestamp::now()).unwrap();
client.open().unwrap();
server.join();
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
}
+140 -1
View File
@@ -1,12 +1,31 @@
use std::{net::SocketAddr, time::Duration};
/// 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
/// before opening a session.
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt};
pub(crate) const DEFAULT_PORT: u16 = 2112;
/// Largest valid DSCP codepoint accepted by client configuration.
///
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
/// socket traffic-class field when configuring packet priority.
pub const MAX_DSCP_CODEPOINT: u8 = 63;
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
///
/// This is the public user-configuration bound for
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
/// TTL and hop-limit settings are configured as `1..=255`.
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.
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
pub const MAX_SERVER_FILL_BYTES: usize = 32;
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
@@ -19,26 +38,110 @@ pub(crate) const MIN_OPEN_TIMEOUT: Duration = Duration::from_millis(200);
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// Configuration for opening and running an IRTT client session.
///
/// 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).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
/// Remote server name or address.
///
/// 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,
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8,
/// Optional HMAC key used to authenticate IRTT packets.
///
/// When present, open, echo, and close packets are encoded and decoded with
/// HMAC authentication. The peer must be configured with the same key.
pub hmac_key: Option<Vec<u8>>,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>,
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub open_timeouts: Vec<Duration>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation_policy: NegotiationPolicy,
/// Local UDP socket configuration.
pub socket_config: SocketConfig,
/// Time after sending a probe before the client reports it as lost.
///
/// 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 {
fn default() -> Self {
Self {
@@ -62,29 +165,65 @@ impl Default for ClientConfig {
}
}
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
///
/// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family.
pub bind_addr: Option<SocketAddr>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
///
/// `None` leaves the platform default unchanged. Values must fit in the
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NegotiationPolicy {
/// Reject any negotiated parameter that is more restrictive or different
/// than requested.
Strict,
/// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams).
Loose,
}
/// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration.
Normal,
/// Complete negotiation without running the echo probe test.
NoTest,
}
/// Bound for a single receive-drain operation.
///
/// This is used by lower-level callers that drive [`Client`](crate::Client)
/// directly and want to cap how many datagrams are processed before returning
/// to their own event loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecvBudget {
/// Maximum number of datagrams to process before returning.
pub max_packets: usize,
}
+111
View File
@@ -2,65 +2,176 @@ use std::{io, time::Duration};
use thiserror::Error;
/// Failure reported while configuring, opening, running, receiving, closing, or
/// joining an IRTT client session.
///
/// Configuration validation errors usually require changing
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
/// Network, protocol, and runtime errors may be transient, but can also reflect
/// peer incompatibility or an invalid session state.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientError {
/// The configured server address could not be resolved to a socket address.
///
/// Check the host, port, address-family restrictions, and name service
/// configuration before retrying.
#[error("failed to resolve server address {addr:?}")]
Resolve { addr: String },
/// A UDP socket operation failed while binding, connecting, sending, or
/// receiving.
///
/// This can be a transient network or OS error, but may also reflect local
/// permissions, routing, or socket configuration.
#[error("socket error: {0}")]
Socket(#[from] io::Error),
/// Applying or reading a socket option failed.
///
/// This is usually caused by an unsupported option, an invalid local value,
/// or a platform-specific socket limitation. Changing socket configuration
/// is more likely to help than retrying unchanged.
#[error("socket option error while trying to {operation} for {remote}: {source}")]
SocketOption {
operation: &'static str,
remote: std::net::SocketAddr,
source: io::Error,
},
/// Restoring the configured read timeout after open negotiation failed.
///
/// The session may have opened successfully, but the socket could not be
/// returned to the caller's requested receive-timeout behavior.
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
ReadTimeoutRestore { source: io::Error },
/// A packet failed IRTT protocol parsing or authentication.
///
/// Likely causes include malformed input, unsupported protocol data, or an
/// HMAC mismatch between client and server.
#[error("protocol error: {0}")]
Protocol(#[from] irtt_proto::ProtoError),
/// No valid open reply was received before all configured open attempts
/// timed out.
///
/// Retrying can succeed when the server or network is temporarily
/// unavailable. Persistent failures usually require changing the address,
/// authentication key, server configuration, or timeout settings.
#[error("all open requests timed out")]
OpenTimeout,
/// An open-attempt timeout is below the client's supported minimum.
///
/// Increase the configured timeout before retrying.
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
OpenTimeoutTooSmall {
timeout: Duration,
minimum: Duration,
},
/// The configured open-attempt timeout list is empty.
///
/// Provide at least one timeout before opening the session.
#[error("open_timeouts must not be empty")]
NoOpenTimeouts,
/// The server explicitly rejected the open request.
///
/// Retrying unchanged is unlikely to help unless the server configuration or
/// load has changed.
#[error("server rejected the open request")]
ServerRejected,
/// A no-test open request received a reply that did not have no-test close
/// semantics.
///
/// This indicates an incompatible or unexpected peer response.
#[error("unexpected no-test open reply")]
UnexpectedNoTestReply,
/// A no-test open reply included a connection token.
///
/// No-test negotiation must not establish a probe session, so a non-zero
/// token indicates an incompatible or unexpected peer response.
#[error("no-test reply included a non-zero connection token: {token}")]
NonZeroNoTestToken { token: u64 },
/// The peer replied with a protocol version different from the requested
/// version.
///
/// This is a protocol compatibility error and requires compatible client
/// and server versions.
#[error("protocol version mismatch: requested {requested}, received {received}")]
ProtocolVersionMismatch { requested: i64, received: i64 },
/// The server returned a zero connection token for a normal test session.
///
/// A normal session requires a non-zero token to correlate echo traffic, so
/// this indicates an invalid peer response.
#[error("server returned a zero connection token")]
ZeroToken,
/// Negotiated parameters were rejected by the configured negotiation policy.
///
/// Use a less restrictive configuration or
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
/// caller accepts documented server restrictions.
#[error("strict negotiation rejected changed params: {reason}")]
NegotiationRejected { reason: String },
/// The requested operation requires an open session, but the client is only
/// connected.
#[error("client is not open")]
NotOpen,
/// The session is already open.
///
/// Continue running, receiving, or closing the existing session instead of
/// opening it again.
#[error("client session is already open")]
AlreadyOpen,
/// The no-test session has already completed.
///
/// Create a new client session to perform another negotiation.
#[error("client session is already completed")]
AlreadyCompleted,
/// The session is already closed.
///
/// Create a new client session before opening or running another test.
#[error("client session is already closed")]
AlreadyClosed,
/// A duration could not be represented as nanoseconds for scheduling or
/// protocol encoding.
///
/// Use smaller configured or negotiated durations.
#[error("duration is too large to encode as nanoseconds")]
DurationOverflow,
/// A client-maintained counter would overflow.
///
/// This is a runtime limit rather than a network failure. Start a new
/// session if the test duration or send volume reaches this limit.
#[error("counter {counter} overflowed")]
CounterOverflow { counter: &'static str },
/// Sending another probe would exceed the configured pending-probe limit.
///
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
/// reduce send rate, or receive replies and timeouts more often before
/// sending again.
#[error("pending probe limit exceeded ({limit})")]
PendingLimitExceeded { limit: usize },
/// Client configuration failed validation before the requested operation.
///
/// The reported reason names a value that must be changed before retrying.
#[error("invalid configuration: {reason}")]
InvalidConfig { reason: String },
/// A managed-session worker thread panicked before producing an outcome.
///
/// This is a runtime failure from joining the managed session, not a
/// protocol response from the server.
#[error("managed client worker thread panicked")]
WorkerPanicked,
}
/// Failure reported while setting up or receiving from an event subscription.
///
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
/// Once a subscription exists, this error type reports delivery failures observed
/// by [`EventSubscription`](crate::EventSubscription).
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EventSubscriptionError {
/// The subscription can no longer receive new events.
///
/// Already queued events are delivered first. After the queue is drained,
/// `recv` and `try_recv` return this error when the hub disconnects the
/// subscriber, the hub is shut down, or the subscription is disconnected by
/// its overflow policy.
#[error("event subscription is disconnected")]
Disconnected,
}
+231 -27
View File
@@ -5,141 +5,360 @@ use std::{
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
/// Result of the IRTT open exchange.
///
/// 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.
#[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,
},
}
/// Event emitted by an IRTT client session.
///
/// Events form the public lifecycle and measurement stream for callers. UIs
/// usually display lifecycle, warning, and per-packet events directly; summary
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
/// `LateReply`, and `DuplicateReply` events or pass the stream to
/// `irtt-stats`.
#[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.
///
/// 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,
},
/// The client sent a close request and considers the session closed.
///
/// This is a local lifecycle event, not an acknowledgement from the server.
/// Managed sessions disconnect event subscribers after the worker exits,
/// while leaving already queued events available to drain.
SessionClosed {
/// Resolved remote socket address.
remote: SocketAddr,
/// Session token that was closed.
token: u64,
/// Client timestamp when the close request was sent.
at: ClientTimestamp,
},
/// An echo request was sent.
///
/// This records local scheduling and send-call timing. It does not imply a
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
/// packet lifecycle state.
EchoSent {
/// Wire sequence number sent in the echo request.
seq: u32,
logical_seq: u64,
/// Resolved remote socket address.
remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent.
scheduled_at: Instant,
/// Client wall/monotonic timestamp captured for the send.
sent_at: ClientTimestamp,
/// Number of bytes passed to the UDP socket.
bytes: usize,
/// Elapsed time spent in the socket send call.
send_call: Duration,
/// Absolute difference between the scheduled send time and actual send
/// timestamp.
timer_error: Duration,
},
/// First in-window reply for a pending echo request.
///
/// This is the primary successful measurement event. RTT and optional
/// server timing/statistics fields should generally be aggregated from this
/// variant.
EchoReply {
/// Wire sequence number from the echo reply.
seq: u32,
logical_seq: u64,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the matching request was sent.
sent_at: ClientTimestamp,
/// Client timestamp recorded when the reply was received.
received_at: ClientTimestamp,
/// Round-trip timing sample for this reply.
rtt: RttSample,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// A sent echo request exceeded the local probe timeout before a reply was
/// matched.
///
/// A later datagram for the same sequence may still arrive and will be
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
EchoLoss {
/// Wire sequence number that timed out.
seq: u32,
logical_seq: u64,
/// Client timestamp recorded when the request was sent.
sent_at: ClientTimestamp,
/// Monotonic deadline at which the probe was declared lost.
timeout_at: std::time::Instant,
},
/// Additional reply for a sequence that was already completed.
///
/// Duplicates are not primary RTT samples. They are useful for diagnostics
/// and packet counters, but should not be aggregated as successful first
/// replies.
DuplicateReply {
/// Wire sequence number from the duplicate reply.
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the duplicate was received.
received_at: ClientTimestamp,
/// UDP datagram size received from the socket.
bytes: usize,
},
/// Reply for a sequence that was no longer pending.
///
/// This commonly means the probe had already been reported as lost, or the
/// reply arrived out of the client's current tracking window. When the
/// original send metadata is still retained, RTT and one-way timing are
/// included; otherwise those fields are `None`.
LateReply {
/// Wire sequence number from the late reply.
seq: u32,
logical_seq: Option<u64>,
/// Highest sequence number accepted as an in-window first reply so far.
highest_seen: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Original client send timestamp, when still retained.
sent_at: Option<ClientTimestamp>,
/// Client timestamp recorded when the late reply was received.
received_at: ClientTimestamp,
/// RTT sample computed when the original send timestamp is available.
rtt: Option<RttSample>,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// Non-fatal condition observed while receiving or classifying packets.
///
/// Warning events are diagnostic. They do not close the session by
/// themselves and are not measurement samples.
Warning {
/// Machine-readable warning category.
kind: WarningKind,
/// Human-readable diagnostic text.
message: String,
/// Client timestamp when the warning was emitted.
at: ClientTimestamp,
},
}
/// Category for a non-fatal [`ClientEvent::Warning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WarningKind {
/// A received datagram could not be decoded as a relevant IRTT packet.
MalformedOrUnrelatedPacket,
/// A decoded packet used a token that does not match the open session.
WrongToken,
/// A decoded reply could not be matched to tracked probe state.
UntrackedReply,
}
/// Round-trip timing for one echo reply.
///
/// `raw` is the client-observed elapsed time from sending the echo request to
/// receiving the reply and is therefore non-negative. When the server reports
/// enough monotonic timing to compute processing time, `adjusted` subtracts
/// that server processing from `raw`. `effective` is the value used by reporting
/// and statistics: adjusted when available, otherwise raw converted to a signed
/// value.
///
/// Adjusted and effective RTT values are signed. They may be negative if the
/// reported server processing time is greater than the raw client-observed RTT.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RttSample {
/// Client-observed RTT from send to receive.
pub raw: Duration,
pub adjusted: Option<Duration>,
pub effective: Duration,
pub adjusted_signed: Option<SignedDuration>,
pub effective_signed: SignedDuration,
/// Signed RTT adjusted for server processing when server processing is available.
pub adjusted: Option<SignedDuration>,
/// Signed effective RTT used by stats and CLI reporting.
///
/// This is `adjusted` when server processing is available, otherwise `raw`
/// converted to a signed duration.
pub effective: SignedDuration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Signed nanosecond-backed duration used for derived timing values.
///
/// This type preserves negative values that can arise after subtracting server
/// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration {
pub ns: i128,
ns: i128,
}
impl SignedDuration {
/// Construct a signed duration from nanoseconds.
pub const fn from_nanos(ns: i128) -> Self {
Self { ns }
}
/// Convert a non-negative [`Duration`] into a signed duration.
///
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
pub fn from_duration(duration: Duration) -> Self {
Self {
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
}
}
/// Return the duration in nanoseconds.
pub const fn as_nanos(self) -> i128 {
self.ns
}
/// Return the duration in whole microseconds, truncated toward zero.
pub const fn as_micros(self) -> i128 {
self.ns / 1_000
}
/// Return the duration in whole milliseconds, truncated toward zero.
pub const fn as_millis(self) -> i128 {
self.ns / 1_000_000
}
/// Return whether this duration is less than zero.
pub const fn is_negative(self) -> bool {
self.ns < 0
}
}
impl From<Duration> for SignedDuration {
fn from(value: Duration) -> Self {
Self::from_duration(value)
}
}
/// Server-reported timing fields decoded from an echo reply.
///
/// These values are optional because the negotiated timestamp mode, clock mode,
/// server implementation, or packet contents may omit individual fields.
/// Timestamp values are nanoseconds in the server's reported wall-clock or
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
/// reported a zero timestamp.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerTiming {
/// Server wall-clock receive timestamp, in nanoseconds.
pub receive_wall_ns: Option<i64>,
/// Server monotonic receive timestamp, in nanoseconds.
pub receive_mono_ns: Option<i64>,
/// Server wall-clock send timestamp, in nanoseconds.
pub send_wall_ns: Option<i64>,
/// Server monotonic send timestamp, in nanoseconds.
pub send_mono_ns: Option<i64>,
/// Server wall-clock midpoint timestamp, in nanoseconds.
pub midpoint_wall_ns: Option<i64>,
/// Server monotonic midpoint timestamp, in nanoseconds.
pub midpoint_mono_ns: Option<i64>,
/// Server processing time computed from monotonic receive/send timestamps.
///
/// `None` means the required monotonic timestamps were unavailable or
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
/// was available and zero.
pub processing: Option<Duration>,
}
/// Directional one-way delay samples computed from wall-clock timestamps.
///
/// Each directional field is an `Option<SignedDuration>`. `None` means the
/// required client or server wall-clock timestamp was unavailable, or the
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
/// computed from available timestamps.
///
/// Negative computed values are preserved as `Some` instead of being clamped to
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
/// between the client and server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OneWayDelaySample {
pub client_to_server: Option<Duration>,
pub server_to_client: Option<Duration>,
/// Client-to-server delay.
///
/// This is computed from the client send wall time and the server receive
/// wall time when both are available.
pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay.
///
/// This is computed from the server send wall time and the client receive
/// wall time when both are available.
pub server_to_client: Option<SignedDuration>,
}
/// Server-reported receive counters decoded from an echo reply.
///
/// These fields are optional because the negotiated received-statistics mode or
/// server implementation may omit them. `None` means unavailable; `Some(0)`
/// means the server reported the value and it was zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceivedStatsSample {
/// Server-reported count of received packets, when available.
pub count: Option<u32>,
/// Server-reported receive window value, when available.
pub window: Option<u64>,
}
@@ -154,18 +373,3 @@ pub struct PacketMeta {
pub ecn: Option<u8>,
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
}
#[cfg(test)]
mod tests {
use super::PacketMeta;
#[test]
fn packet_meta_default_is_unavailable() {
let meta = PacketMeta::default();
assert_eq!(meta.traffic_class, None);
assert_eq!(meta.dscp, None);
assert_eq!(meta.ecn, None);
assert_eq!(meta.kernel_rx_timestamp, None);
}
}
+56 -5
View File
@@ -1,3 +1,50 @@
//! Reusable client/session/event layer for IRTT-compatible probes.
//!
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
//! values for callers to consume directly or aggregate with `irtt-stats`.
//!
//! Timing values intentionally preserve signed measurement semantics. When
//! server timing is available, [`RttSample::effective`] is adjusted for server
//! processing and can be negative if the reported server processing time exceeds
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
//! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew.
//!
//! A managed session can drive the socket loop on a worker thread and publish
//! events through a subscription:
//!
//! ```no_run
//! use std::time::Duration;
//!
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = ClientConfig {
//! server_addr: "127.0.0.1:2112".to_owned(),
//! duration: Some(Duration::from_secs(10)),
//! interval: Duration::from_secs(1),
//! ..ClientConfig::default()
//! };
//!
//! let (session, events) =
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
//!
//! while let Ok(event) = events.recv() {
//! match event {
//! ClientEvent::EchoReply { seq, rtt, .. } => {
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
//! }
//! ClientEvent::SessionClosed { .. } => break,
//! _ => {}
//! }
//! }
//!
//! let _outcome = session.join()?;
//! # Ok(())
//! # }
//! ```
//!
#![cfg_attr(
not(all(target_os = "linux", feature = "ancillary")),
forbid(unsafe_code)
@@ -12,6 +59,7 @@ mod managed;
mod metadata;
mod probe;
mod receive;
mod runtime;
mod session;
mod socket;
mod socket_options;
@@ -19,8 +67,8 @@ mod timing;
pub use client::Client;
pub use config::{
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT,
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
};
pub use error::{ClientError, EventSubscriptionError};
pub use event::{
@@ -28,8 +76,11 @@ pub use event::{
ServerTiming, SignedDuration, WarningKind,
};
pub use managed::{
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession,
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow,
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientGroup,
ManagedClientGroupConfig, ManagedClientGroupSession, ManagedClientSession,
ManagedGroupEndReason, ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig,
ManagedTargetEndReason, ManagedTargetOutcome, SessionEndReason, SessionOutcome,
SubscriberConfig, SubscriberOverflow, TargetEvent, TargetEventSubscription, TargetId,
};
pub use session::NegotiatedParams;
pub use session::{NegotiatedParams, NegotiationRestriction};
pub use timing::ClientTimestamp;
+11 -5
View File
@@ -3,20 +3,31 @@ use std::sync::{
Arc,
};
/// Cloneable cooperative cancellation flag.
///
/// Managed sessions use this internally to ask their worker loop to stop. It is
/// exported for callers that want the same simple cancellation primitive in
/// code built around `irtt-client`.
#[derive(Debug, Clone, Default)]
pub struct CancellationToken {
cancelled: Arc<AtomicBool>,
}
impl CancellationToken {
/// Create a token in the non-cancelled state.
pub fn new() -> Self {
Self::default()
}
/// Mark the token as cancelled.
///
/// Cancellation is idempotent and is visible through all clones of the
/// token.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// Return whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
@@ -27,11 +38,6 @@ impl CancellationToken {
mod tests {
use super::*;
#[test]
fn token_starts_uncancelled() {
assert!(!CancellationToken::new().is_cancelled());
}
#[test]
fn cancel_is_idempotent_and_visible_through_clones() {
let token = CancellationToken::new();
File diff suppressed because it is too large. Load diff
+125 -104
View File
@@ -8,9 +8,17 @@ use std::{
use crate::{error::EventSubscriptionError, event::ClientEvent};
/// Configuration for one managed event subscriber.
///
/// Each subscriber has an independent bounded queue. The queue bounds memory
/// growth when event production is faster than that subscriber's consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubscriberConfig {
/// Maximum number of events buffered for this subscriber.
///
/// Must be greater than zero.
pub capacity: usize,
/// Policy applied when publishing to a full subscriber queue.
pub overflow: SubscriberOverflow,
}
@@ -37,15 +45,20 @@ pub enum SubscriberOverflow {
Disconnect,
}
#[derive(Debug, Clone)]
pub struct EventHub {
inner: Arc<HubInner>,
/// Publish/subscribe fan-out for managed event values.
///
/// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber.
#[derive(Debug)]
pub struct EventHub<T = ClientEvent> {
inner: Arc<HubInner<T>>,
}
#[derive(Debug)]
struct HubInner {
struct HubInner<T> {
next_id: AtomicU64,
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner>>>,
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner<T>>>>,
}
/// Handle for receiving managed client events.
@@ -55,26 +68,35 @@ struct HubInner {
/// when that queue is full. Dropping the handle unregisters the subscriber.
#[must_use = "dropping the subscription unregisters it"]
#[derive(Debug)]
pub struct EventSubscription {
pub struct EventSubscription<T = ClientEvent> {
id: u64,
hub: Weak<HubInner>,
inner: Arc<SubscriberInner>,
hub: Weak<HubInner<T>>,
inner: Arc<SubscriberInner<T>>,
}
#[derive(Debug)]
struct SubscriberInner {
state: Mutex<SubscriberState>,
struct SubscriberInner<T> {
state: Mutex<SubscriberState<T>>,
available: Condvar,
config: SubscriberConfig,
}
#[derive(Debug)]
struct SubscriberState {
queue: VecDeque<ClientEvent>,
struct SubscriberState<T> {
queue: VecDeque<T>,
connected: bool,
}
impl EventHub {
impl<T> Clone for EventHub<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T> EventHub<T> {
/// Create an empty event hub with no subscribers.
pub fn new() -> Self {
Self {
inner: Arc::new(HubInner {
@@ -84,10 +106,13 @@ impl EventHub {
}
}
/// Register a new subscriber with its own bounded queue.
///
/// Returns an error when `config.capacity` is zero.
pub fn subscribe(
&self,
config: SubscriberConfig,
) -> Result<EventSubscription, crate::ClientError> {
) -> Result<EventSubscription<T>, crate::ClientError> {
if config.capacity == 0 {
return Err(crate::ClientError::InvalidConfig {
reason: "subscriber capacity must be greater than zero".to_owned(),
@@ -116,8 +141,41 @@ impl EventHub {
})
}
pub fn publish(&self, event: ClientEvent) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner<T>>> = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.drain()
.map(|(_, subscriber)| subscriber)
.collect();
for subscriber in subscribers {
subscriber.disconnect();
}
}
#[cfg(test)]
fn subscriber_count(&self) -> usize {
self.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.len()
}
}
impl<T: Clone> EventHub<T> {
/// Publish an event to all currently connected subscribers.
///
/// Slow subscribers are handled according to their
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
/// subscriber to consume queued events.
pub fn publish(&self, event: T) {
let subscribers: Vec<(u64, Arc<SubscriberInner<T>>)> = self
.inner
.subscribers
.lock()
@@ -148,45 +206,21 @@ impl EventHub {
subscribers.remove(&id);
}
}
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.drain()
.map(|(_, subscriber)| subscriber)
.collect();
for subscriber in subscribers {
subscriber.disconnect();
}
}
#[cfg(test)]
fn subscriber_count(&self) -> usize {
self.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.len()
}
}
impl Default for EventHub {
impl<T> Default for EventHub<T> {
fn default() -> Self {
Self::new()
}
}
impl EventSubscription {
impl<T> EventSubscription<T> {
/// Block until the next queued event is available.
///
/// If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained.
pub fn recv(&self) -> Result<ClientEvent, EventSubscriptionError> {
pub fn recv(&self) -> Result<T, EventSubscriptionError> {
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
loop {
if let Some(event) = state.queue.pop_front() {
@@ -209,7 +243,7 @@ impl EventSubscription {
/// is queued. If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained.
pub fn try_recv(&self) -> Result<Option<ClientEvent>, EventSubscriptionError> {
pub fn try_recv(&self) -> Result<Option<T>, EventSubscriptionError> {
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
if let Some(event) = state.queue.pop_front() {
return Ok(Some(event));
@@ -221,7 +255,7 @@ impl EventSubscription {
}
}
impl Drop for EventSubscription {
impl<T> Drop for EventSubscription<T> {
fn drop(&mut self) {
self.inner.disconnect();
if let Some(hub) = self.hub.upgrade() {
@@ -233,8 +267,8 @@ impl Drop for EventSubscription {
}
}
impl SubscriberInner {
fn publish(&self, event: ClientEvent) -> bool {
impl<T> SubscriberInner<T> {
fn publish(&self, event: T) -> bool {
let mut state = self.state.lock().expect("subscriber mutex poisoned");
if !state.connected {
return false;
@@ -279,6 +313,7 @@ mod tests {
ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
message: format!("event-{n}"),
at: ClientTimestamp::now(),
}
}
@@ -289,14 +324,16 @@ mod tests {
}
}
#[test]
fn publishes_to_one_subscriber() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.publish(event(1));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
fn assert_next(
sub: &EventSubscription,
expected: Result<Option<&str>, EventSubscriptionError>,
) {
match (sub.try_recv(), expected) {
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
(Ok(None), Ok(None)) => {}
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
}
#[test]
@@ -312,56 +349,40 @@ mod tests {
}
#[test]
fn drop_newest_keeps_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropNewest,
})
.unwrap();
fn full_subscriber_overflow_policy_is_applied() {
let cases = [
(
SubscriberOverflow::DropNewest,
Ok(Some("event-1")),
Ok(None),
),
(
SubscriberOverflow::DropOldest,
Ok(Some("event-2")),
Ok(None),
),
(
SubscriberOverflow::Disconnect,
Err(EventSubscriptionError::Disconnected),
Err(EventSubscriptionError::Disconnected),
),
];
hub.publish(event(1));
hub.publish(event(2));
for (overflow, first_expected, second_expected) in cases {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow,
})
.unwrap();
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
assert!(sub.try_recv().unwrap().is_none());
}
hub.publish(event(1));
hub.publish(event(2));
#[test]
fn drop_oldest_replaces_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropOldest,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
assert!(sub.try_recv().unwrap().is_none());
}
#[test]
fn disconnect_removes_full_subscriber() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::Disconnect,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(
sub.try_recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
assert_next(&sub, first_expected);
assert_next(&sub, second_expected);
}
}
#[test]
@@ -400,7 +421,7 @@ mod tests {
#[test]
fn dropping_subscription_unregisters_from_hub() {
let hub = EventHub::new();
let hub: EventHub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
assert_eq!(hub.subscriber_count(), 1);
@@ -411,7 +432,7 @@ mod tests {
#[test]
fn disconnect_all_wakes_blocking_receivers() {
let hub = EventHub::new();
let hub: EventHub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.disconnect_all();
+6
View File
@@ -1,7 +1,13 @@
mod cancellation;
mod group;
mod hub;
mod runner;
pub use cancellation::CancellationToken;
pub use group::{
ManagedClientGroup, ManagedClientGroupConfig, ManagedClientGroupSession, ManagedGroupEndReason,
ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig, ManagedTargetEndReason,
ManagedTargetOutcome, TargetEvent, TargetEventSubscription, TargetId,
};
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
+160 -28
View File
@@ -7,7 +7,6 @@ use crate::{
config::{ClientConfig, RecvBudget},
error::ClientError,
event::{ClientEvent, OpenOutcome},
timing::ClientTimestamp,
Client,
};
@@ -22,6 +21,12 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
const IDLE_SLEEP: Duration = Duration::from_millis(1);
const MAX_SLEEP: Duration = Duration::from_millis(20);
/// Entry point for running a client session on a worker thread.
///
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
/// opens the session, sends probes at the negotiated interval, receives
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
/// run completes or is cancelled.
#[derive(Debug)]
pub struct ManagedClient;
@@ -47,26 +52,49 @@ pub struct ManagedClientSession {
#[must_use = "managed session outcomes contain completion status and counters"]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionOutcome {
/// Why the managed loop stopped.
pub end_reason: SessionEndReason,
/// Number of echo requests sent by the client.
pub packets_sent: u64,
/// Number of first in-window echo replies received.
pub replies_received: u64,
/// Number of duplicate reply events emitted.
pub duplicates: u64,
/// Number of late reply events emitted.
pub late: u64,
/// Number of warning events emitted.
pub warning_events: u64,
}
/// Reason a managed client session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionEndReason {
/// The negotiated finite test duration completed and the client closed the
/// session normally.
TestComplete,
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
/// dropping the session handle.
Cancelled,
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
/// completed after negotiation.
NoTestComplete,
}
impl ManagedClient {
/// Start a managed session without creating an initial event subscription.
///
/// The returned [`ManagedClientSession`] can still create subscriptions via
/// [`ManagedClientSession::subscribe`], but events emitted before a
/// subscriber is registered are not replayed.
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
Self::start_inner(config, None).map(|(session, _)| session)
}
/// Start a managed session and subscribe to events before the worker runs.
///
/// The initial subscription receives the open lifecycle event and
/// subsequent session events, subject to its queue capacity and overflow
/// policy.
pub fn start_with_subscription(
config: ClientConfig,
subscriber_config: SubscriberConfig,
@@ -94,7 +122,7 @@ impl ManagedClient {
.transpose()?;
let mut client = Client::connect(config)?;
let outcome = client.open(ClientTimestamp::now())?;
let outcome = client.open()?;
publish_open_outcome(&hub, &outcome);
let cancellation = CancellationToken::new();
@@ -115,14 +143,28 @@ impl ManagedClient {
}
impl ManagedClientSession {
/// Add another event subscriber to this running managed session.
///
/// The subscription receives only events published after it is registered.
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
self.hub.subscribe(config)
}
/// Request cooperative cancellation of the managed session.
///
/// Cancellation is observed by the worker loop between socket receive,
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
/// wait for the worker and obtain the final [`SessionOutcome`].
pub fn stop(&self) {
self.cancellation.cancel();
}
/// Wait for the managed worker thread to finish.
///
/// On success, returns lifecycle counters for the completed session. If the
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
/// also disconnects all event subscriptions while leaving already queued
/// events available to drain.
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
let worker = self
.worker
@@ -182,11 +224,9 @@ fn run_client(
&mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?,
);
publish_events(
&hub,
&mut counters,
client.poll_timeouts(ClientTimestamp::now())?,
);
if !client.is_peer_closed() {
publish_events(&hub, &mut counters, client.poll_timeouts()?);
}
break;
}
@@ -205,11 +245,10 @@ fn run_client(
&mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?,
);
publish_events(
&hub,
&mut counters,
client.poll_timeouts(ClientTimestamp::now())?,
);
if client.is_peer_closed() {
break;
}
publish_events(&hub, &mut counters, client.poll_timeouts()?);
if client.is_run_complete() {
break;
@@ -223,8 +262,10 @@ fn run_client(
}
let packets_sent = client.packets_sent();
let close_events = client.close(ClientTimestamp::now())?;
publish_events(&hub, &mut counters, close_events);
if !client.is_peer_closed() {
let close_events = client.close()?;
publish_events(&hub, &mut counters, close_events);
}
Ok(SessionOutcome {
end_reason: if cancelled {
@@ -261,13 +302,21 @@ fn drain_final_late_replies(
let deadline = Instant::now() + MANAGED_FINAL_DRAIN;
while Instant::now() < deadline && client.has_timed_out_metadata() {
if client.is_peer_closed() {
break;
}
let mut published = false;
let events = client.recv_available(MANAGED_RECV_BUDGET)?;
published |= !events.is_empty();
publish_events(hub, counters, events);
let events = client.poll_timeouts(ClientTimestamp::now())?;
if client.is_peer_closed() {
break;
}
let events = client.poll_timeouts()?;
published |= !events.is_empty();
publish_events(hub, counters, events);
@@ -320,8 +369,10 @@ mod tests {
use super::*;
use crate::{config::NegotiationPolicy, SubscriberOverflow};
use irtt_proto::{
echo_packet_len, flags, flags::FLAG_OPEN, flags::FLAG_REPLY, layout::PacketLayout, Clock,
Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
echo_packet_len,
flags::{self, FLAG_OPEN, FLAG_REPLY},
layout::PacketLayout,
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
};
use std::{
net::{SocketAddr, UdpSocket},
@@ -340,6 +391,11 @@ mod tests {
}
}
fn test_echo_packet_len(hmac: bool, params: &Params) -> usize {
echo_packet_len(hmac, params)
.expect("managed runner test params must have a non-negative packet length")
}
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
Params {
protocol_version: PROTOCOL_VERSION,
@@ -440,6 +496,53 @@ mod tests {
FakeServer { addr, done }
}
fn start_peer_close_server(params: Params) -> FakeServer {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let addr = socket.local_addr().unwrap();
let done = thread::spawn(move || {
let (_, peer) = recv_request(&socket);
socket
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params), peer)
.unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let Some((packet, peer)) = recv_request_timeout(&socket) else {
return;
};
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
let ts = TimestampFields {
recv_wall: Some(1_000_000_000),
recv_mono: Some(100_000),
send_wall: Some(1_000_000_000),
send_mono: Some(100_000),
..Default::default()
};
socket
.send_to(
&echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&ts,
FLAG_REPLY | flags::FLAG_CLOSE,
),
peer,
)
.unwrap();
if let Some((packet, _)) = recv_request_timeout(&socket) {
panic!(
"managed cleanup must not send any packet after peer close; flags={:?} len={}",
packet.get(3).copied(),
packet.len()
);
}
});
FakeServer { addr, done }
}
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
let mut buf = [0_u8; 2048];
let (size, peer) = socket.recv_from(&mut buf).unwrap();
@@ -468,13 +571,23 @@ mod tests {
seq: u32,
params: &Params,
timestamps: &TimestampFields,
) -> Vec<u8> {
echo_reply_packet_with_flags(token, seq, params, timestamps, FLAG_REPLY)
}
fn echo_reply_packet_with_flags(
token: u64,
seq: u32,
params: &Params,
timestamps: &TimestampFields,
flags: u8,
) -> Vec<u8> {
let layout = PacketLayout::echo(false, params);
let packet_len = echo_packet_len(false, params);
let packet_len = test_echo_packet_len(false, params);
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_REPLY);
packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&seq.to_le_bytes());
@@ -603,19 +716,13 @@ mod tests {
server.join();
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
assert!(events.iter().any(|event| matches!(
event,
ClientEvent::EchoLoss {
seq: 0,
logical_seq: 0,
..
}
)));
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::EchoLoss { seq: 0, .. })));
assert!(events.iter().any(|event| matches!(
event,
ClientEvent::LateReply {
seq: 0,
logical_seq: Some(0),
sent_at: Some(_),
rtt: Some(_),
..
@@ -632,6 +739,31 @@ mod tests {
assert!(late_before_close < close);
}
#[test]
fn peer_close_during_managed_run_is_successful() {
let server = start_peer_close_server(test_params(None, Duration::from_millis(10)));
let (session, sub) = ManagedClient::start_with_subscription(
config(server.addr, None),
SubscriberConfig {
capacity: 16,
overflow: SubscriberOverflow::DropNewest,
},
)
.unwrap();
let events = collect_until_closed(&sub);
let outcome = session.join().unwrap();
server.join();
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
}
#[test]
fn continuous_managed_run_can_be_stopped_cleanly() {
let server = start_echo_server(test_params(None, Duration::from_millis(10)));
-11
View File
@@ -24,17 +24,6 @@ mod tests {
use super::ReceiveMeta;
use crate::event::PacketMeta;
#[test]
fn metadata_default_is_unavailable() {
let meta = ReceiveMeta::default();
let packet_meta = PacketMeta::from(meta);
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[test]
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
let packet_meta = PacketMeta::from(ReceiveMeta {
+27 -61
View File
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
collections::{HashMap, HashSet, VecDeque},
time::Instant,
};
@@ -7,7 +7,6 @@ use crate::{error::ClientError, timing::ClientTimestamp};
#[derive(Debug, Clone)]
pub(crate) struct PendingProbe {
pub logical_seq: u64,
pub wire_seq: u32,
pub sent_at: ClientTimestamp,
pub timeout_at: Instant,
@@ -59,11 +58,10 @@ impl PendingMap {
expired.push(probe);
}
}
expired.sort_by_key(|p| p.logical_seq);
expired.sort_by_key(|p| p.sent_at.mono);
expired
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
@@ -115,7 +113,6 @@ impl TimedOutMap {
self.insertion_order.clear();
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
@@ -136,7 +133,7 @@ impl TimedOutMap {
#[derive(Debug)]
pub(crate) struct CompletedSet {
set: HashMap<u32, u64>,
set: HashSet<u32>,
insertion_order: VecDeque<u32>,
max_capacity: usize,
}
@@ -144,31 +141,32 @@ pub(crate) struct CompletedSet {
impl CompletedSet {
pub fn new(max_capacity: usize) -> Self {
Self {
set: HashMap::new(),
set: HashSet::new(),
insertion_order: VecDeque::new(),
max_capacity,
}
}
pub fn insert(&mut self, wire_seq: u32, logical_seq: u64) {
if let Some(existing) = self.set.get_mut(&wire_seq) {
*existing = logical_seq;
pub fn insert(&mut self, seq: u32) {
if self.set.contains(&seq) {
return;
}
if self.set.len() >= self.max_capacity {
self.evict_oldest();
}
self.insertion_order.push_back(wire_seq);
self.set.insert(wire_seq, logical_seq);
self.insertion_order.push_back(seq);
self.set.insert(seq);
}
pub fn contains(&self, wire_seq: u32) -> bool {
self.set.contains_key(&wire_seq)
pub fn contains(&self, seq: u32) -> bool {
self.set.contains(&seq)
}
fn evict_oldest(&mut self) {
while let Some(oldest_key) = self.insertion_order.pop_front() {
if self.set.remove(&oldest_key).is_some() {
while let Some(oldest_seq) = self.insertion_order.pop_front() {
if self.set.remove(&oldest_seq) {
break;
}
}
@@ -187,79 +185,47 @@ mod tests {
}
}
fn pending(seq: u32, logical: u64, timeout_at: Instant) -> PendingProbe {
fn pending(seq: u32, timeout_at: Instant) -> PendingProbe {
PendingProbe {
logical_seq: logical,
wire_seq: seq,
sent_at: ts(timeout_at - Duration::from_secs(1)),
timeout_at,
}
}
#[test]
fn pending_map_insert_and_remove() {
let mut map = PendingMap::new(10);
let now = Instant::now();
let probe = pending(0, 0, now + Duration::from_secs(4));
map.insert(probe).unwrap();
assert_eq!(map.len(), 1);
assert!(map.remove(0).is_some());
assert_eq!(map.len(), 0);
assert!(map.remove(0).is_none());
}
#[test]
fn pending_map_rejects_over_capacity() {
let mut map = PendingMap::new(2);
let now = Instant::now();
map.insert(pending(0, 0, now + Duration::from_secs(4)))
map.insert(pending(0, now + Duration::from_secs(4)))
.unwrap();
map.insert(pending(1, 1, now + Duration::from_secs(4)))
map.insert(pending(1, now + Duration::from_secs(4)))
.unwrap();
assert!(matches!(
map.insert(pending(2, 2, now + Duration::from_secs(4))),
map.insert(pending(2, now + Duration::from_secs(4))),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
}
#[test]
fn drain_expired_returns_in_logical_order() {
let mut map = PendingMap::new(10);
let now = Instant::now();
let past = now - Duration::from_secs(1);
map.insert(pending(5, 5, past)).unwrap();
map.insert(pending(2, 2, past)).unwrap();
map.insert(pending(8, 8, now + Duration::from_secs(10)))
.unwrap();
let expired = map.drain_expired(now);
assert_eq!(expired.len(), 2);
assert_eq!(expired[0].wire_seq, 2);
assert_eq!(expired[1].wire_seq, 5);
assert_eq!(map.len(), 1);
}
#[test]
fn completed_set_tracks_and_evicts() {
fn bounded_probe_tracking_evicts_oldest_entries() {
let mut set = CompletedSet::new(3);
set.insert(0, 0);
set.insert(1, 1);
set.insert(2, 2);
set.insert(0);
set.insert(1);
set.insert(2);
assert!(set.contains(0));
assert!(set.contains(1));
assert!(set.contains(2));
set.insert(3, 3);
set.insert(3);
assert_eq!(set.set.len(), 3);
assert!(!set.contains(0));
assert!(set.contains(3));
}
#[test]
fn timed_out_map_tracks_and_evicts() {
let mut map = TimedOutMap::new(2);
let now = Instant::now();
map.insert(pending(0, 0, now));
map.insert(pending(1, 1, now));
map.insert(pending(2, 2, now));
map.insert(pending(0, now));
map.insert(pending(1, now));
map.insert(pending(2, now));
assert_eq!(map.len(), 2);
assert!(map.remove(0).is_none());
@@ -273,7 +239,7 @@ mod tests {
let now = Instant::now();
for i in 0..20 {
map.insert(pending(i, u64::from(i), now));
map.insert(pending(i, now));
assert!(map.remove(i).is_some());
assert_eq!(map.len(), 0);
assert_eq!(map.insertion_order_len(), 0);
+32
View File
@@ -15,6 +15,14 @@ pub(crate) struct ReceivedDatagram {
pub(crate) meta: ReceiveMeta,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReceivedDatagramFrom {
pub(crate) len: usize,
pub(crate) source: SocketAddr,
pub(crate) received_at: ClientTimestamp,
pub(crate) meta: ReceiveMeta,
}
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn recv_datagram(
socket: &UdpSocket,
@@ -30,6 +38,22 @@ pub(crate) fn recv_datagram(
})
}
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn recv_datagram_from(
socket: &UdpSocket,
buf: &mut [u8],
) -> Result<ReceivedDatagramFrom, io::Error> {
let (len, source) = socket.recv_from(buf)?;
let received_at = ClientTimestamp::now();
Ok(ReceivedDatagramFrom {
len,
source,
received_at,
meta: ReceiveMeta::default(),
})
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
pub(crate) fn recv_datagram(
socket: &UdpSocket,
@@ -38,6 +62,14 @@ pub(crate) fn recv_datagram(
linux::recv_datagram(socket, buf)
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
pub(crate) fn recv_datagram_from(
socket: &UdpSocket,
buf: &mut [u8],
) -> Result<ReceivedDatagramFrom, io::Error> {
linux::recv_datagram_from(socket, buf)
}
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn configure_receive_metadata(
_socket: &UdpSocket,
+107 -2
View File
@@ -1,13 +1,17 @@
#![allow(unsafe_code)]
use std::{
io, mem,
net::{SocketAddr, UdpSocket},
net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6, UdpSocket},
os::fd::AsRawFd,
ptr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::{metadata::ReceiveMeta, receive::ReceivedDatagram, timing::ClientTimestamp};
use crate::{
metadata::ReceiveMeta,
receive::{ReceivedDatagram, ReceivedDatagramFrom},
timing::ClientTimestamp,
};
const CONTROL_LEN: usize = 128;
@@ -87,6 +91,61 @@ pub(crate) fn recv_datagram(
})
}
pub(crate) fn recv_datagram_from(
socket: &UdpSocket,
buf: &mut [u8],
) -> Result<ReceivedDatagramFrom, io::Error> {
let mut control = ControlBuffer::new();
let mut name: libc::sockaddr_storage = unsafe { mem::zeroed() };
let mut iov = libc::iovec {
iov_base: buf.as_mut_ptr().cast(),
iov_len: buf.len(),
};
let mut msg: libc::msghdr = unsafe { mem::zeroed() };
msg.msg_name = (&mut name as *mut libc::sockaddr_storage).cast();
msg.msg_namelen = mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.as_mut_ptr().cast();
msg.msg_controllen = control.len();
let len = unsafe {
// SAFETY: `msg` points to one writable iovec backed by `buf`, a
// writable source-address buffer, and a writable control buffer. All
// buffers live until `recvmsg` returns. The socket file descriptor is
// borrowed from a valid `UdpSocket`.
libc::recvmsg(socket.as_raw_fd(), &mut msg, 0)
};
if len < 0 {
return Err(io::Error::last_os_error());
}
let len = usize::try_from(len).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"recvmsg returned an unrepresentable datagram length",
)
})?;
let received_at = ClientTimestamp::now();
let source = unsafe {
// SAFETY: `name` and `msg_namelen` were initialized by a successful
// `recvmsg` call above.
socket_addr_from_storage(&name, msg.msg_namelen)?
};
let meta = unsafe {
// SAFETY: `msg` was initialized by a successful `recvmsg` call. The
// parser only reads cmsghdr entries within `msg_controllen` and ignores
// short or unrelated control messages.
parse_receive_meta(&msg)
};
Ok(ReceivedDatagramFrom {
len,
source,
received_at,
meta,
})
}
unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
let mut meta = ReceiveMeta::default();
let mut cmsg = libc::CMSG_FIRSTHDR(msg);
@@ -108,6 +167,52 @@ unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
meta
}
unsafe fn socket_addr_from_storage(
storage: &libc::sockaddr_storage,
len: libc::socklen_t,
) -> io::Result<SocketAddr> {
match i32::from(storage.ss_family) {
libc::AF_INET => {
if usize::try_from(len).unwrap_or(0) < mem::size_of::<libc::sockaddr_in>() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"short IPv4 source address from recvmsg",
));
}
let addr = ptr::read_unaligned(
(storage as *const libc::sockaddr_storage).cast::<libc::sockaddr_in>(),
);
let ip = Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr));
let port = u16::from_be(addr.sin_port);
Ok(SocketAddr::V4(SocketAddrV4::new(ip, port)))
}
libc::AF_INET6 => {
if usize::try_from(len).unwrap_or(0) < mem::size_of::<libc::sockaddr_in6>() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"short IPv6 source address from recvmsg",
));
}
let addr = ptr::read_unaligned(
(storage as *const libc::sockaddr_storage).cast::<libc::sockaddr_in6>(),
);
let ip = Ipv6Addr::from(addr.sin6_addr.s6_addr);
let port = u16::from_be(addr.sin6_port);
let flowinfo = u32::from_be(addr.sin6_flowinfo);
Ok(SocketAddr::V6(SocketAddrV6::new(
ip,
port,
flowinfo,
addr.sin6_scope_id,
)))
}
family => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported source address family from recvmsg: {family}"),
)),
}
}
fn is_traffic_class_cmsg(cmsg: &libc::cmsghdr) -> bool {
(cmsg.cmsg_level == libc::IPPROTO_IP && cmsg.cmsg_type == libc::IP_TOS)
|| (cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_TCLASS)
+864
View File
@@ -0,0 +1,864 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use irtt_proto::{
close::CloseRequest, decode_echo_reply, echo_packet_len, encode_close_request,
encode_echo_request, encode_open_request, flags, EchoReply, EchoRequest, OpenReply,
OpenRequest, Params, ServerFill, TimestampFields, PROTOCOL_VERSION,
};
use crate::{
config::{
ClientConfig, RunMode, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_UDP_PAYLOAD_LENGTH,
},
error::ClientError,
event::{
ClientEvent, OneWayDelaySample, OpenOutcome, ReceivedStatsSample, RttSample, ServerTiming,
SignedDuration, WarningKind,
},
metadata::ReceiveMeta,
probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
session::{negotiate_params, ActiveSession, ClientPhase, CloseSource, NegotiatedParams},
timing::ClientTimestamp,
};
pub(crate) const MAX_OPEN_PACKET_SIZE: usize = 512;
const MIN_RECV_BUFFER_SIZE: usize = 2048;
#[derive(Debug)]
pub(crate) struct SessionRuntime {
config: ClientConfig,
remote: std::net::SocketAddr,
requested: Params,
negotiated: Option<NegotiatedParams>,
phase: ClientPhase,
session: Option<ActiveSession>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SendProbeResult {
pub(crate) sent_at: ClientTimestamp,
pub(crate) bytes: usize,
pub(crate) send_call: Duration,
}
impl SessionRuntime {
pub(crate) fn new(
config: ClientConfig,
remote: std::net::SocketAddr,
) -> Result<Self, ClientError> {
if config.max_pending_probes == 0 {
return Err(ClientError::InvalidConfig {
reason: "max_pending_probes must be greater than zero".to_owned(),
});
}
if config.probe_timeout == Duration::ZERO {
return Err(ClientError::InvalidConfig {
reason: "probe_timeout must be greater than zero".to_owned(),
});
}
let requested = params_from_config(&config)?;
Ok(Self {
config,
remote,
requested,
negotiated: None,
phase: ClientPhase::Connected,
session: None,
})
}
pub(crate) fn config(&self) -> &ClientConfig {
&self.config
}
pub(crate) fn has_hmac(&self) -> bool {
self.config.hmac_key.is_some()
}
pub(crate) fn open_packet(&self) -> Result<Vec<u8>, ClientError> {
match self.phase {
ClientPhase::Connected => {}
ClientPhase::Open { .. } => return Err(ClientError::AlreadyOpen),
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
}
let request = OpenRequest {
params: self.requested.clone(),
close: self.config.run_mode == RunMode::NoTest,
};
Ok(encode_open_request(
&request,
self.config.hmac_key.as_deref(),
)?)
}
pub(crate) fn decode_open_reply(&self, packet: &[u8]) -> Result<OpenReply, ClientError> {
Ok(irtt_proto::decode_open_reply(
packet,
self.config.hmac_key.as_deref(),
)?)
}
pub(crate) fn accept_open_reply<F>(
&mut self,
reply: OpenReply,
now: ClientTimestamp,
before_normal_open: F,
) -> Result<OpenOutcome, ClientError>
where
F: FnOnce(&NegotiatedParams) -> Result<(), ClientError>,
{
if reply.params.protocol_version != PROTOCOL_VERSION {
return Err(ClientError::ProtocolVersionMismatch {
requested: PROTOCOL_VERSION,
received: reply.params.protocol_version,
});
}
let reply_is_close = flags::has(reply.flags, flags::FLAG_CLOSE);
match self.config.run_mode {
RunMode::Normal if reply_is_close => Err(ClientError::ServerRejected),
RunMode::Normal if reply.token == 0 => Err(ClientError::ZeroToken),
RunMode::Normal => self.accept_normal_open(reply, now, before_normal_open),
RunMode::NoTest if !reply_is_close => Err(ClientError::UnexpectedNoTestReply),
RunMode::NoTest if reply.token != 0 => {
Err(ClientError::NonZeroNoTestToken { token: reply.token })
}
RunMode::NoTest => self.accept_no_test_open(reply, now),
}
}
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
let session = self.session.as_ref()?;
if session.sending_done {
return None;
}
Some(session.next_send_at)
}
pub(crate) fn probe_timeout(&self) -> Duration {
self.config.probe_timeout
}
pub(crate) fn send_probe_with<F>(
&mut self,
override_ts: Option<ClientTimestamp>,
send: F,
) -> Result<Vec<ClientEvent>, ClientError>
where
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
{
self.send_probe_inner(override_ts, None, send)
}
pub(crate) fn send_probe_for_deadline<F>(
&mut self,
scheduled_at: Instant,
send: F,
) -> Result<Vec<ClientEvent>, ClientError>
where
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
{
self.send_probe_inner(None, Some(scheduled_at), send)
}
fn send_probe_inner<F>(
&mut self,
override_ts: Option<ClientTimestamp>,
scheduled_at_override: Option<Instant>,
send: F,
) -> Result<Vec<ClientEvent>, ClientError>
where
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
{
let token = match self.phase {
ClientPhase::Open { token } => token,
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
ClientPhase::Connected => return Err(ClientError::NotOpen),
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
};
let session = self
.session
.as_mut()
.expect("session must exist when phase is Open");
if session.sending_done {
return Ok(vec![]);
}
let now = override_ts.unwrap_or_else(ClientTimestamp::now);
if let Some(end) = session.end_mono {
if now.mono >= end {
session.sending_done = true;
return Ok(vec![]);
}
}
session.pending.check_capacity()?;
let negotiated = self
.negotiated
.as_ref()
.expect("negotiated must exist when Open");
let wire_seq = session.next_wire_seq;
let scheduled_at = scheduled_at_override.unwrap_or(session.next_send_at);
let request = EchoRequest {
token,
sequence: wire_seq,
params: negotiated.params.clone(),
payload: vec![],
};
let packet = encode_echo_request(&request, self.config.hmac_key.as_deref())?;
let send_result = send(&packet)?;
let timer_error = instant_abs_diff(send_result.sent_at.mono, scheduled_at);
let session = self
.session
.as_mut()
.expect("session must exist when phase is Open");
let pending = PendingProbe {
wire_seq,
sent_at: send_result.sent_at,
timeout_at: send_result
.sent_at
.mono
.checked_add(self.config.probe_timeout)
.ok_or(ClientError::DurationOverflow)?,
};
session.pending.insert(pending)?;
session.next_wire_seq = session.next_wire_seq.wrapping_add(1);
session.packets_sent =
session
.packets_sent
.checked_add(1)
.ok_or(ClientError::CounterOverflow {
counter: "packets_sent",
})?;
let negotiated = self
.negotiated
.as_ref()
.expect("negotiated must exist when Open");
let interval_ns = u64::try_from(negotiated.params.interval_ns)
.expect("validated positive negotiated interval");
let session = self
.session
.as_mut()
.expect("session must exist when phase is Open");
session.next_send_at = if let Some(scheduled_at) = scheduled_at_override {
scheduled_at
.checked_add(Duration::from_nanos(interval_ns))
.ok_or(ClientError::DurationOverflow)?
} else {
next_probe_deadline(session.start_mono, interval_ns, session.packets_sent)?
};
if let Some(end) = session.end_mono {
if session.next_send_at >= end {
session.sending_done = true;
}
}
Ok(vec![ClientEvent::EchoSent {
seq: wire_seq,
remote: self.remote,
scheduled_at,
sent_at: send_result.sent_at,
bytes: send_result.bytes,
send_call: send_result.send_call,
timer_error,
}])
}
pub(crate) fn process_received_echo_packet(
&mut self,
packet: &[u8],
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> {
match self.phase {
ClientPhase::Open { .. } => {}
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
ClientPhase::Connected => return Err(ClientError::NotOpen),
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
}
let Some(reply) = self.decode_received_packet(packet) else {
return Ok(vec![ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
message: "dropped malformed or unrelated packet".to_owned(),
at: now,
}]);
};
self.process_echo_reply(reply, packet.len(), now, meta)
}
pub(crate) fn poll_timeouts_at(
&mut self,
now: Instant,
) -> Result<Vec<ClientEvent>, ClientError> {
match self.phase {
ClientPhase::Open { .. } => {}
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
ClientPhase::Connected => return Err(ClientError::NotOpen),
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
}
let session = self
.session
.as_mut()
.expect("session must exist when phase is Open");
let expired = session.pending.drain_expired(now);
let mut events = Vec::with_capacity(expired.len());
for probe in expired {
events.push(ClientEvent::EchoLoss {
seq: probe.wire_seq,
sent_at: probe.sent_at,
timeout_at: probe.timeout_at,
});
session.timed_out.insert(probe);
}
Ok(events)
}
pub(crate) fn close_with<F>(&mut self, send: F) -> Result<Vec<ClientEvent>, ClientError>
where
F: FnOnce(&[u8]) -> Result<(), ClientError>,
{
let token = match self.phase {
ClientPhase::Open { token } => token,
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
ClientPhase::Connected | ClientPhase::NoTestCompleted => {
return Err(ClientError::NotOpen)
}
};
let packet =
encode_close_request(&CloseRequest { token }, self.config.hmac_key.as_deref())?;
send(&packet)?;
self.phase = ClientPhase::Closed {
source: CloseSource::Local,
};
if let Some(session) = self.session.as_mut() {
session.timed_out.clear();
}
self.session = None;
Ok(vec![ClientEvent::SessionClosed {
remote: self.remote,
token,
at: ClientTimestamp::now(),
}])
}
pub(crate) fn is_run_complete(&self) -> bool {
let Some(session) = self.session.as_ref() else {
return matches!(
self.phase,
ClientPhase::Closed { .. } | ClientPhase::NoTestCompleted
);
};
session.sending_done && session.pending.len() == 0
}
pub(crate) fn is_peer_closed(&self) -> bool {
matches!(
self.phase,
ClientPhase::Closed {
source: CloseSource::Peer
}
)
}
pub(crate) fn has_timed_out_metadata(&self) -> bool {
self.session
.as_ref()
.is_some_and(|session| session.timed_out.len() > 0)
}
pub(crate) fn packets_sent(&self) -> u64 {
self.session
.as_ref()
.map_or(0, |session| session.packets_sent)
}
pub(crate) fn is_open(&self) -> bool {
matches!(self.phase, ClientPhase::Open { .. })
}
fn accept_normal_open<F>(
&mut self,
reply: OpenReply,
now: ClientTimestamp,
before_normal_open: F,
) -> Result<OpenOutcome, ClientError>
where
F: FnOnce(&NegotiatedParams) -> Result<(), ClientError>,
{
let token = reply.token;
let negotiated = negotiate_params(
&self.requested,
reply.params,
self.config.negotiation_policy,
)?;
before_normal_open(&negotiated)?;
self.negotiated = Some(negotiated.clone());
self.phase = ClientPhase::Open { token };
let end_mono = if negotiated.params.duration_ns > 0 {
Some(negotiated_end_mono(
now.mono,
negotiated.params.duration_ns,
)?)
} else {
None
};
self.session = Some(ActiveSession {
next_wire_seq: 0,
highest_received_seq: None,
packets_sent: 0,
start_mono: now.mono,
end_mono,
next_send_at: now.mono,
pending: PendingMap::new(self.config.max_pending_probes),
timed_out: TimedOutMap::new(self.config.max_pending_probes),
completed: CompletedSet::new(self.config.max_pending_probes),
sending_done: false,
});
let event = ClientEvent::SessionStarted {
remote: self.remote,
token,
negotiated: negotiated.clone(),
at: now,
};
Ok(OpenOutcome::Started {
remote: self.remote,
token,
negotiated,
event,
})
}
fn accept_no_test_open(
&mut self,
reply: OpenReply,
now: ClientTimestamp,
) -> Result<OpenOutcome, ClientError> {
let negotiated = negotiate_params(
&self.requested,
reply.params,
self.config.negotiation_policy,
)?;
self.negotiated = Some(negotiated.clone());
self.phase = ClientPhase::NoTestCompleted;
let event = ClientEvent::NoTestCompleted {
remote: self.remote,
negotiated: negotiated.clone(),
at: now,
};
Ok(OpenOutcome::NoTestCompleted {
remote: self.remote,
negotiated,
event,
})
}
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
let negotiated = self
.negotiated
.as_ref()
.expect("negotiated must exist when Open");
decode_echo_reply(packet, &negotiated.params, self.config.hmac_key.as_deref()).ok()
}
fn process_echo_reply(
&mut self,
reply: EchoReply,
packet_len: usize,
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> {
let token = match self.phase {
ClientPhase::Open { token } => token,
_ => unreachable!(),
};
if reply.token != token {
return Ok(vec![ClientEvent::Warning {
kind: WarningKind::WrongToken,
message: format!(
"dropped reply with wrong token: expected {token:#x}, got {:#x}",
reply.token
),
at: now,
}]);
}
let session = self.session.as_mut().expect("session must exist when Open");
let wire_seq = reply.sequence;
if let Some(pending) = session.pending.remove(wire_seq) {
let rtt = compute_rtt(&pending.sent_at, &now, &reply.timestamps);
let server_timing = build_server_timing(&reply.timestamps);
let one_way = compute_one_way(&pending.sent_at, &now, &reply.timestamps);
let received_stats = build_received_stats(&reply);
let is_late = session
.highest_received_seq
.is_some_and(|h| sequence_is_before(wire_seq, h));
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq);
let mut events = Vec::new();
if is_late {
events.push(ClientEvent::LateReply {
seq: wire_seq,
highest_seen,
remote: self.remote,
sent_at: Some(pending.sent_at),
received_at: now,
rtt: Some(rtt),
server_timing,
one_way,
received_stats,
bytes: packet_len,
packet_meta: meta.into(),
});
} else {
events.push(ClientEvent::EchoReply {
seq: wire_seq,
remote: self.remote,
sent_at: pending.sent_at,
received_at: now,
rtt,
server_timing,
one_way,
received_stats,
bytes: packet_len,
packet_meta: meta.into(),
});
}
if flags::has(reply.flags, flags::FLAG_CLOSE) {
self.close_from_peer(token, now, &mut events);
}
Ok(events)
} else if session.completed.contains(wire_seq) {
update_highest_received(&mut session.highest_received_seq, wire_seq);
Ok(vec![ClientEvent::DuplicateReply {
seq: wire_seq,
remote: self.remote,
received_at: now,
bytes: packet_len,
}])
} else if let Some(timed_out) = session.timed_out.remove(wire_seq) {
let rtt = compute_rtt(&timed_out.sent_at, &now, &reply.timestamps);
let server_timing = build_server_timing(&reply.timestamps);
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
let received_stats = build_received_stats(&reply);
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq);
let mut events = vec![ClientEvent::LateReply {
seq: wire_seq,
highest_seen,
remote: self.remote,
sent_at: Some(timed_out.sent_at),
received_at: now,
rtt: Some(rtt),
server_timing,
one_way,
received_stats,
bytes: packet_len,
packet_meta: meta.into(),
}];
if flags::has(reply.flags, flags::FLAG_CLOSE) {
self.close_from_peer(token, now, &mut events);
}
Ok(events)
} else if session
.highest_received_seq
.is_some_and(|h| sequence_is_before(wire_seq, h))
{
Ok(vec![ClientEvent::LateReply {
seq: wire_seq,
highest_seen: session.highest_received_seq.unwrap(),
remote: self.remote,
sent_at: None,
received_at: now,
rtt: None,
server_timing: build_server_timing(&reply.timestamps),
one_way: None,
received_stats: build_received_stats(&reply),
bytes: packet_len,
packet_meta: meta.into(),
}])
} else {
Ok(vec![ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
message: format!(
"dropped reply with untracked seq {wire_seq} (no pending or completed entry)"
),
at: now,
}])
}
}
fn close_from_peer(&mut self, token: u64, now: ClientTimestamp, events: &mut Vec<ClientEvent>) {
self.phase = ClientPhase::Closed {
source: CloseSource::Peer,
};
if let Some(session) = self.session.as_mut() {
session.timed_out.clear();
}
self.session = None;
events.push(ClientEvent::SessionClosed {
remote: self.remote,
token,
at: now,
});
}
}
pub(crate) fn recv_buffer_size(
has_hmac: bool,
negotiated: Option<&NegotiatedParams>,
) -> Result<usize, ClientError> {
Ok(match negotiated {
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.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)?;
Ok(Params {
protocol_version: PROTOCOL_VERSION,
duration_ns: match config.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(config.dscp),
server_fill: config.server_fill.clone().map(|value| ServerFill { value }),
})
}
pub(crate) fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
if sequence_is_after(wire_seq, h) {
wire_seq
} else {
h
}
}));
}
fn next_probe_deadline(
start: Instant,
interval_ns: u64,
packets_sent: u64,
) -> Result<Instant, ClientError> {
let offset_ns = interval_ns
.checked_mul(packets_sent)
.ok_or(ClientError::DurationOverflow)?;
start
.checked_add(Duration::from_nanos(offset_ns))
.ok_or(ClientError::DurationOverflow)
}
pub(crate) fn sequence_is_after(candidate: u32, current: u32) -> bool {
candidate != current && candidate.wrapping_sub(current) < (1 << 31)
}
pub(crate) fn sequence_is_before(candidate: u32, current: u32) -> bool {
current != candidate && current.wrapping_sub(candidate) < (1 << 31)
}
fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
left.checked_duration_since(right)
.or_else(|| right.checked_duration_since(left))
.unwrap_or(Duration::ZERO)
}
pub(crate) fn compute_rtt(
sent_at: &ClientTimestamp,
received_at: &ClientTimestamp,
ts: &TimestampFields,
) -> RttSample {
let raw = received_at
.mono
.checked_duration_since(sent_at.mono)
.unwrap_or(Duration::ZERO);
let server_processing = compute_server_processing(ts);
let adjusted = server_processing
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
RttSample {
raw,
adjusted,
effective,
}
}
fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
}
fn compute_server_processing(ts: &TimestampFields) -> Option<Duration> {
if let (Some(recv_mono), Some(send_mono)) = (ts.recv_mono, ts.send_mono) {
let diff = send_mono.checked_sub(recv_mono)?;
return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
}
if let (Some(recv_wall), Some(send_wall)) = (ts.recv_wall, ts.send_wall) {
let diff = send_wall.checked_sub(recv_wall)?;
return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
}
None
}
fn build_server_timing(ts: &TimestampFields) -> Option<ServerTiming> {
if ts.recv_wall.is_none()
&& ts.recv_mono.is_none()
&& ts.send_wall.is_none()
&& ts.send_mono.is_none()
&& ts.midpoint_wall.is_none()
&& ts.midpoint_mono.is_none()
{
return None;
}
Some(ServerTiming {
receive_wall_ns: ts.recv_wall,
receive_mono_ns: ts.recv_mono,
send_wall_ns: ts.send_wall,
send_mono_ns: ts.send_mono,
midpoint_wall_ns: ts.midpoint_wall,
midpoint_mono_ns: ts.midpoint_mono,
processing: compute_server_processing(ts),
})
}
pub(crate) fn compute_one_way(
sent_at: &ClientTimestamp,
received_at: &ClientTimestamp,
ts: &TimestampFields,
) -> Option<OneWayDelaySample> {
let server_recv_wall = ts.recv_wall.or(ts.midpoint_wall);
let server_send_wall = ts.send_wall.or(ts.midpoint_wall);
let client_send_ns = unix_epoch_ns_i64(sent_at.wall);
let client_recv_ns = unix_epoch_ns_i64(received_at.wall);
let c2s = server_recv_wall
.zip(client_send_ns)
.and_then(|(srv, cli)| srv.checked_sub(cli))
.map(|d| SignedDuration::from_nanos(i128::from(d)));
let s2c = client_recv_ns
.zip(server_send_wall)
.and_then(|(cli, srv)| cli.checked_sub(srv))
.map(|d| SignedDuration::from_nanos(i128::from(d)));
if c2s.is_none() && s2c.is_none() {
return None;
}
Some(OneWayDelaySample {
client_to_server: c2s,
server_to_client: s2c,
})
}
pub(crate) fn unix_epoch_ns_i64(time: SystemTime) -> Option<i64> {
time.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_nanos()).ok())
}
fn build_received_stats(reply: &EchoReply) -> Option<ReceivedStatsSample> {
if reply.recv_count.is_none() && reply.recv_window.is_none() {
return None;
}
Some(ReceivedStatsSample {
count: reply.recv_count,
window: reply.recv_window,
})
}
fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
if config.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 {
return Err(ClientError::InvalidConfig {
reason: "interval must be greater than zero".to_owned(),
});
}
if config.dscp > MAX_DSCP_CODEPOINT {
return Err(ClientError::InvalidConfig {
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
});
}
if config.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 {
let len = fill.len();
if len == 0 {
return Err(ClientError::InvalidConfig {
reason: "server_fill must not be empty".to_owned(),
});
}
if len > MAX_SERVER_FILL_BYTES {
return Err(ClientError::InvalidConfig {
reason: format!("server_fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"),
});
}
}
Ok(())
}
fn config_duration_to_ns(field: &str, duration: Duration) -> Result<i64, ClientError> {
i64::try_from(duration.as_nanos()).map_err(|_| ClientError::InvalidConfig {
reason: format!("{field} is too large to encode as nanoseconds"),
})
}
fn negotiated_end_mono(start: Instant, duration_ns: i64) -> Result<Instant, ClientError> {
debug_assert!(duration_ns > 0);
let duration_ns = u64::try_from(duration_ns).expect("validated positive negotiated duration");
start
.checked_add(Duration::from_nanos(duration_ns))
.ok_or_else(|| ClientError::NegotiationRejected {
reason: "duration is too large to schedule".to_owned(),
})
}
+343 -27
View File
@@ -1,6 +1,6 @@
use std::time::Instant;
use std::{fmt, time::Instant};
use irtt_proto::{Params, PROTOCOL_VERSION};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
use crate::{
config::{NegotiationPolicy, MAX_DSCP_CODEPOINT},
@@ -8,9 +8,152 @@ use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
};
/// Protocol parameters accepted for a session.
///
/// `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.
#[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>,
}
/// A server-side restriction applied during session parameter negotiation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NegotiationRestriction {
/// Run duration was reduced.
///
/// 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.
DurationReduced {
requested_ns: i64,
negotiated_ns: i64,
},
/// Probe interval was increased.
IntervalIncreased {
requested_ns: i64,
negotiated_ns: i64,
},
/// Probe interval was reduced.
IntervalReduced {
requested_ns: i64,
negotiated_ns: i64,
},
/// 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.
DscpChanged { requested: i64, negotiated: i64 },
/// Returned server payload fill behavior differs from the request.
ServerFillChanged,
}
impl NegotiationRestriction {
/// Return a human-readable description of this negotiated restriction.
pub fn message(&self) -> String {
self.to_string()
}
}
impl fmt::Display for NegotiationRestriction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DurationReduced {
requested_ns: 0,
negotiated_ns,
} => {
write!(
f,
"server limited continuous duration to {negotiated_ns} ns"
)
}
Self::DurationReduced {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server reduced duration from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::IntervalIncreased {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server increased interval from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::IntervalReduced {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server reduced interval from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::LengthReduced {
requested,
negotiated,
} => {
write!(
f,
"server reduced packet length from {requested} bytes to {negotiated} bytes"
)
}
Self::ReceivedStatsChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed received-stats from {requested:?} to {negotiated:?}"
)
}
Self::StampAtChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed stamp-at from {requested:?} to {negotiated:?}"
)
}
Self::ClockChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed clock from {requested:?} to {negotiated:?}"
)
}
Self::DscpChanged {
requested,
negotiated,
} => {
write!(f, "server changed DSCP from {requested} to {negotiated}")
}
Self::ServerFillChanged => write!(f, "server changed payload fill behavior"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -18,13 +161,18 @@ pub(crate) enum ClientPhase {
Connected,
Open { token: u64 },
NoTestCompleted,
Closed,
Closed { source: CloseSource },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CloseSource {
Local,
Peer,
}
#[derive(Debug)]
pub(crate) struct ActiveSession {
pub next_wire_seq: u32,
pub next_logical_seq: u64,
pub highest_received_seq: Option<u32>,
pub packets_sent: u64,
pub start_mono: Instant,
@@ -36,17 +184,19 @@ pub(crate) struct ActiveSession {
pub sending_done: bool,
}
pub(crate) fn validate_negotiated_params(
pub(crate) fn negotiate_params(
requested: &Params,
returned: &Params,
returned: Params,
policy: NegotiationPolicy,
) -> Result<(), ClientError> {
) -> Result<NegotiatedParams, ClientError> {
if returned.protocol_version != PROTOCOL_VERSION {
return Err(ClientError::ProtocolVersionMismatch {
requested: PROTOCOL_VERSION,
received: returned.protocol_version,
});
}
let mut restrictions = Vec::new();
validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
if returned.length < 0 {
return Err(ClientError::NegotiationRejected {
@@ -65,12 +215,105 @@ pub(crate) fn validate_negotiated_params(
}
validate_dscp_restriction(returned.dscp)?;
if policy == NegotiationPolicy::Strict && returned != requested {
if returned.duration_ns < requested.duration_ns
|| (requested.duration_ns == 0 && returned.duration_ns > 0)
{
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
},
)?;
}
if returned.interval_ns > requested.interval_ns {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::IntervalIncreased {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
},
)?;
}
if returned.interval_ns < requested.interval_ns {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::IntervalReduced {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
},
)?;
}
if returned.length < requested.length {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
},
)?;
}
if returned.received_stats != requested.received_stats {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
},
)?;
}
if returned.stamp_at != requested.stamp_at {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
},
)?;
}
if returned.clock != requested.clock {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
},
)?;
}
if returned.dscp != requested.dscp && returned.dscp != 0 {
return Err(ClientError::NegotiationRejected {
reason: "returned params differ from requested params".to_owned(),
reason: "server returned unsupported DSCP change".to_owned(),
});
}
Ok(())
if returned.dscp == 0 && requested.dscp != 0 {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
},
)?;
}
if returned.server_fill != requested.server_fill {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ServerFillChanged,
)?;
}
Ok(NegotiatedParams {
params: returned,
restrictions,
})
}
fn validate_duration_restriction(requested: i64, returned: i64) -> Result<(), ClientError> {
@@ -105,6 +348,21 @@ fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
Ok(())
}
fn record_restriction(
policy: NegotiationPolicy,
restrictions: &mut Vec<NegotiationRestriction>,
restriction: NegotiationRestriction,
) -> Result<(), ClientError> {
if policy == NegotiationPolicy::Strict {
return Err(ClientError::NegotiationRejected {
reason: restriction.message(),
});
}
restrictions.push(restriction);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -128,17 +386,26 @@ mod tests {
fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
assert!(matches!(
validate_negotiated_params(requested, returned, policy),
negotiate_params(requested, returned.clone(), policy),
Err(ClientError::NegotiationRejected { .. })
));
}
fn assert_negotiates(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> NegotiatedParams {
negotiate_params(requested, returned.clone(), policy)
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
}
fn rejection_reason(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> String {
match validate_negotiated_params(requested, returned, policy) {
match negotiate_params(requested, returned.clone(), policy) {
Err(ClientError::NegotiationRejected { reason }) => reason,
other => panic!("expected negotiation rejection, got {other:?}"),
}
@@ -150,27 +417,65 @@ mod tests {
let mut returned = requested.clone();
returned.length = 128;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
}
.message()
);
let mut returned = requested.clone();
returned.dscp = 8;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
}
.message()
);
let mut returned = requested.clone();
returned.received_stats = ReceivedStats::Count;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
}
.message()
);
let mut returned = requested.clone();
returned.stamp_at = StampAt::Midpoint;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
}
.message()
);
let mut returned = requested.clone();
returned.clock = Clock::Wall;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
}
.message()
);
let mut returned = requested.clone();
returned.server_fill = None;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ServerFillChanged.message()
);
}
#[test]
@@ -179,8 +484,13 @@ mod tests {
let mut returned = requested.clone();
returned.duration_ns = requested.duration_ns / 2;
assert!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose).is_ok()
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
}]
);
let mut returned = requested.clone();
@@ -201,12 +511,18 @@ mod tests {
continuous_requested.duration_ns = 0;
let mut finite_returned = continuous_requested.clone();
finite_returned.duration_ns = 1_000_000_000;
assert!(validate_negotiated_params(
let negotiated = assert_negotiates(
&continuous_requested,
&finite_returned,
NegotiationPolicy::Loose
)
.is_ok());
NegotiationPolicy::Loose,
);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: 0,
negotiated_ns: finite_returned.duration_ns,
}]
);
assert_rejected(
&continuous_requested,
@@ -214,9 +530,9 @@ mod tests {
NegotiationPolicy::Strict,
);
assert!(validate_negotiated_params(
&continuous_requested,
assert!(negotiate_params(
&continuous_requested,
continuous_requested.clone(),
NegotiationPolicy::Strict
)
.is_ok());
+38
View File
@@ -99,6 +99,44 @@ pub(crate) fn connect_udp_socket(
Ok(socket)
}
pub(crate) fn bind_unconnected_udp_socket(
config: &SocketConfig,
family_remote: SocketAddr,
) -> Result<UdpSocket, ClientError> {
let domain = if family_remote.is_ipv4() {
Domain::IPV4
} else {
Domain::IPV6
};
let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
if config.ipv6_only && family_remote.is_ipv6() {
socket.set_only_v6(true)?;
}
let bind_addr = config.bind_addr.unwrap_or_else(|| {
if family_remote.is_ipv4() {
SocketAddr::from(([0, 0, 0, 0], 0))
} else {
SocketAddr::from(([0_u16; 8], 0))
}
});
socket.bind(&bind_addr.into())?;
let socket: UdpSocket = socket.into();
configure_receive_metadata(&socket, family_remote).map_err(|source| {
ClientError::SocketOption {
operation: "enable receive metadata",
remote: family_remote,
source,
}
})?;
if let Some(ttl) = config.ttl {
apply_ttl_to_socket(&socket, family_remote, ttl)?;
}
socket.set_read_timeout(config.recv_timeout)?;
Ok(socket)
}
#[cfg(test)]
mod tests {
use super::*;
+8
View File
@@ -1,12 +1,20 @@
use std::time::{Instant, SystemTime};
/// Client-side timestamp captured in both wall-clock and monotonic time.
///
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
/// calculations. Monotonic time is used for elapsed client-side timing such as
/// RTT, scheduling, and timeouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientTimestamp {
/// System wall-clock time.
pub wall: SystemTime,
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
pub mono: Instant,
}
impl ClientTimestamp {
/// Capture the current wall-clock and monotonic timestamps.
pub fn now() -> Self {
Self {
wall: SystemTime::now(),
+42 -85
View File
@@ -3,10 +3,9 @@ mod support;
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, OpenOutcome, SocketConfig,
WarningKind,
Client, ClientConfig, ClientError, ClientEvent, OpenOutcome, SocketConfig, WarningKind,
};
use irtt_proto::{echo_packet_len, ProtoError, TimestampFields};
use irtt_proto::{ProtoError, TimestampFields};
use support::{
config_for_params, default_params, params_for_modes, run_one_probe_with_config,
@@ -14,6 +13,8 @@ use support::{
start_hmac_required_open_drop_server, BackendPeer, ServerObservation, TOKEN,
};
use crate::support::test_echo_packet_len;
#[test]
fn hmac_open_success_negotiates_without_warnings() {
let key = b"compat-secret".to_vec();
@@ -63,7 +64,7 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
token,
sequence,
} => {
assert_eq!(*len, echo_packet_len(true, &params));
assert_eq!(*len, test_echo_packet_len(true, &params));
assert!(*hmac);
assert_eq!(*token, TOKEN);
assert_eq!(*sequence, 0);
@@ -74,14 +75,12 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
match &run.reply {
ClientEvent::EchoReply {
seq,
logical_seq,
bytes,
server_timing,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*logical_seq, 0);
assert_eq!(*bytes, echo_packet_len(true, &params));
assert_eq!(*bytes, test_echo_packet_len(true, &params));
assert!(server_timing.is_some());
}
other => panic!("expected EchoReply, got {other:?}"),
@@ -98,10 +97,10 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
config.hmac_key = Some(key);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let events = client.close(ClientTimestamp::now()).unwrap();
let events = client.close().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::SessionClosed { token: TOKEN, .. }]
@@ -123,53 +122,27 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
}
#[test]
fn missing_client_key_against_hmac_required_server_times_out() {
let key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_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 = None;
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
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 client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: false,
bad_hmac: true
}]
));
server.join();
}
#[test]
fn wrong_client_key_against_hmac_required_server_times_out() {
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 = Some(b"wrong-secret".to_vec());
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: true,
bad_hmac: true
}]
));
server.join();
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac,
bad_hmac: true
}] if *hmac == expected_hmac_flag
));
server.join();
}
}
#[test]
@@ -185,7 +158,7 @@ fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
};
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let sent = client.send_probe().unwrap();
@@ -229,7 +202,7 @@ fn hmac_open_reply_with_bad_hmac_fails_with_protocol_error() {
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(ProtoError::BadHmac))
));
server.join();
@@ -247,7 +220,7 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let observations = server.observations(1);
@@ -304,7 +277,7 @@ fn backend_hmac_correct_key_succeeds() {
config.hmac_key = Some(key);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
@@ -314,35 +287,19 @@ fn backend_hmac_correct_key_succeeds() {
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
}
#[test]
fn backend_hmac_wrong_key_fails() {
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 = Some(b"wrong-secret".to_vec());
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 client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
}
#[test]
fn backend_hmac_missing_key_fails() {
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 = None;
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
}
+14 -12
View File
@@ -1,10 +1,12 @@
mod support;
use irtt_client::{Client, ClientConfig, ClientEvent, ClientTimestamp, NegotiationPolicy};
use irtt_proto::{echo_header_len, echo_packet_len, Params, TimestampFields};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy};
use irtt_proto::{echo_header_len, Params, TimestampFields};
use support::{config_for_params, default_params, run_one_probe, BackendPeer, ServerObservation};
use crate::support::test_echo_packet_len;
fn params_with_length(length: i64) -> Params {
Params {
length,
@@ -42,7 +44,7 @@ fn assert_reply_bytes(event: &ClientEvent, expected: usize) {
#[test]
fn length_zero_baseline() {
let params = params_with_length(0);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
let header = echo_header_len(false, &params);
assert_eq!(expected, header);
}
@@ -50,21 +52,21 @@ fn length_zero_baseline() {
#[test]
fn length_one_accepted() {
let params = params_with_length(1);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert!(expected >= 1);
}
#[test]
fn length_1472_accepted() {
let params = params_with_length(1472);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(expected, 1472);
}
#[test]
fn length_4096_accepted() {
let params = params_with_length(4096);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(expected, 4096);
}
@@ -74,7 +76,7 @@ fn length_4096_accepted() {
fn echo_request_size_matches_echo_packet_len_for_zero_length() {
let params = params_with_length(0);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_echo_size(&run, expected);
assert_sent_bytes(&run.sent, expected);
}
@@ -84,7 +86,7 @@ fn echo_request_size_matches_echo_packet_len_above_header() {
for length in [16, 60, 128, 1472, 4096] {
let params = params_with_length(length);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(
expected,
echo_header_len(false, &params).max(length as usize)
@@ -100,7 +102,7 @@ fn echo_request_size_matches_echo_packet_len_above_header() {
fn echo_reply_decoded_for_large_length() {
let params = params_with_length(4096);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_reply(&run.reply);
assert_reply_bytes(&run.reply, expected);
}
@@ -110,7 +112,7 @@ fn echo_reply_bytes_consistent_with_echo_packet_len_for_various_lengths() {
for length in [0, 1472, 4096] {
let params = params_with_length(length);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_reply_bytes(&run.reply, expected);
}
}
@@ -128,7 +130,7 @@ fn backend_large_packet_smoke() {
})
.unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
client.send_probe().unwrap();
@@ -136,5 +138,5 @@ fn backend_large_packet_smoke() {
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
}
+23 -180
View File
@@ -1,23 +1,26 @@
mod support;
mod protocol_options {
pub mod backend_smoke;
}
use irtt_client::ClientTimestamp;
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
RttSample, ServerTiming,
RttSample, ServerTiming, SignedDuration,
};
use irtt_proto::{echo_packet_len, Clock, Params, ReceivedStats, StampAt, TimestampFields};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
use support::{
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
RECV_COUNT, RECV_WINDOW, TOKEN,
};
use crate::support::test_echo_packet_len;
struct ReplyView<'a> {
seq: u32,
logical_seq: u64,
rtt: &'a RttSample,
server_timing: Option<&'a ServerTiming>,
one_way: Option<&'a OneWayDelaySample>,
@@ -39,8 +42,7 @@ fn received_stats_modes_drive_negotiated_echo_and_reply_events() {
let reply = expect_echo_reply(&run.reply);
assert_eq!(reply.seq, 0);
assert_eq!(reply.logical_seq, 0);
assert_eq!(reply.bytes, echo_packet_len(false, &params));
assert_eq!(reply.bytes, test_echo_packet_len(false, &params));
assert!(reply.server_timing.is_none());
assert!(reply.one_way.is_none());
assert_received_stats(reply.received_stats, expected_count, expected_window);
@@ -67,7 +69,10 @@ fn timestamp_modes_drive_reply_event_timing_fields() {
assert!(reply.server_timing.is_none());
assert!(reply.one_way.is_none());
assert!(reply.rtt.adjusted.is_none());
assert_eq!(reply.rtt.effective, reply.rtt.raw);
assert_eq!(
reply.rtt.effective,
SignedDuration::from_duration(reply.rtt.raw)
);
assert!(reply.rtt.raw > Duration::ZERO);
}
StampAt::Send => {
@@ -149,7 +154,7 @@ fn hmac_rich_mode_uses_negotiated_echo_layout_and_decodes_reply() {
assert_negotiated_echo_use(&run, &params, true);
let reply = expect_echo_reply(&run.reply);
assert_eq!(reply.bytes, echo_packet_len(true, &params));
assert_eq!(reply.bytes, test_echo_packet_len(true, &params));
assert_received_stats(reply.received_stats, Some(RECV_COUNT), Some(RECV_WINDOW));
assert_timing_fields(reply.server_timing, true, true, false, false, true, true);
}
@@ -167,7 +172,7 @@ fn strict_open_rejects_changed_compatibility_params_from_server() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(irtt_client::ClientTimestamp::now()),
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
@@ -186,7 +191,7 @@ fn loose_open_uses_returned_params_for_echo_layout_and_reply_parsing() {
returned.received_stats = ReceivedStats::Count;
returned.stamp_at = StampAt::Receive;
returned.clock = Clock::Wall;
returned.dscp = 8;
returned.dscp = 0;
returned.server_fill = None;
let requested_for_config = requested.clone();
@@ -246,21 +251,15 @@ fn assert_negotiated_echo_use(run: &OneProbeRun, params: &Params, hmac: bool) {
fn assert_echo_uses_params(run: &OneProbeRun, params: &Params, hmac: bool) {
let (echo_len, echo_hmac, token, sequence) = echo_observation(run);
assert_eq!(echo_len, echo_packet_len(hmac, params));
assert_eq!(echo_len, test_echo_packet_len(hmac, params));
assert_eq!(echo_hmac, hmac);
assert_eq!(token, TOKEN);
assert_eq!(sequence, 0);
match &run.sent {
ClientEvent::EchoSent {
seq,
logical_seq,
bytes,
..
} => {
ClientEvent::EchoSent { seq, bytes, .. } => {
assert_eq!(*seq, 0);
assert_eq!(*logical_seq, 0);
assert_eq!(*bytes, echo_packet_len(hmac, params));
assert_eq!(*bytes, test_echo_packet_len(hmac, params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
@@ -289,7 +288,6 @@ fn expect_echo_reply(event: &ClientEvent) -> ReplyView<'_> {
match event {
ClientEvent::EchoReply {
seq,
logical_seq,
rtt,
server_timing,
one_way,
@@ -298,7 +296,6 @@ fn expect_echo_reply(event: &ClientEvent) -> ReplyView<'_> {
..
} => ReplyView {
seq: *seq,
logical_seq: *logical_seq,
rtt,
server_timing: server_timing.as_ref(),
one_way: one_way.as_ref(),
@@ -355,7 +352,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
assert_eq!(processing, Duration::from_nanos(1));
let adjusted = reply.rtt.adjusted.unwrap();
assert_eq!(reply.rtt.effective, adjusted);
assert!(adjusted < reply.rtt.raw);
assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
}
fn assert_one_way_presence(
@@ -373,46 +370,6 @@ fn assert_one_way_presence(
assert_eq!(sample.server_to_client.is_some(), server_to_client);
}
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
#[test]
fn dscp_configured_open_close_smoke() {
let mut params = default_params();
@@ -420,122 +377,8 @@ fn dscp_configured_open_close_smoke() {
let server = start_open_server(params.clone(), None);
let mut client = Client::connect(config_for_params(server.addr, &params)).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
server.join();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close(ClientTimestamp::now()).unwrap();
}
}
#[test]
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(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close(ClientTimestamp::now()).unwrap();
}
}
@@ -0,0 +1,158 @@
use irtt_client::{Client, ClientEvent};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use crate::support::{config_for_params, default_params, params_for_modes, BackendPeer};
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
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(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
+49 -113
View File
@@ -1,15 +1,15 @@
mod support;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, NegotiationPolicy,
};
use irtt_proto::{echo_packet_len, Params, TimestampFields};
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
use irtt_proto::{Params, TimestampFields};
use support::{
config_for_params, default_params, run_one_probe, run_one_probe_with_config, server_fill,
start_open_server, OneProbeRun, ServerObservation,
};
use crate::support::test_echo_packet_len;
fn open_observation_params(run: &OneProbeRun) -> &Params {
match &run.observations[0] {
ServerObservation::Open { params, .. } => params,
@@ -32,63 +32,34 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
// ─── Config mapping ───
#[test]
fn server_fill_none_produces_no_fill_in_open_params() {
let params = default_params();
let run = run_one_probe(params, TimestampFields::default());
assert!(open_observation_params(&run).server_fill.is_none());
fn server_fill_none_short_and_max_map_into_open_params() {
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
let mut params = default_params();
params.server_fill = fill.and_then(server_fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
fill
);
}
}
#[test]
fn server_fill_short_value_maps_into_open_params() {
let mut params = default_params();
params.server_fill = server_fill("abc");
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some("abc")
);
}
#[test]
fn server_fill_32_byte_value_maps_into_open_params() {
let mut params = default_params();
let fill = "0123456789abcdef0123456789abcdef";
params.server_fill = server_fill(fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some(fill)
);
}
#[test]
fn server_fill_over_32_bytes_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn server_fill_empty_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
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()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
// ─── Open negotiation ───
@@ -102,53 +73,26 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
}
#[test]
fn server_fill_strict_rejects_removed_fill() {
let mut requested = default_params();
requested.server_fill = server_fill("rand");
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
for (requested_fill, returned_fill) in [
(Some("rand"), None),
(Some("abc"), Some("xyz")),
(None, Some("unexpected")),
] {
let mut requested = default_params();
requested.server_fill = requested_fill.and_then(server_fill);
let mut returned = requested.clone();
returned.server_fill = None;
let mut returned = requested.clone();
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();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn server_fill_strict_rejects_different_fill_value() {
let mut requested = default_params();
requested.server_fill = server_fill("abc");
let mut returned = requested.clone();
returned.server_fill = server_fill("xyz");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn server_fill_strict_rejects_unexpected_fill_from_server() {
let requested = default_params();
let mut returned = requested.clone();
returned.server_fill = server_fill("unexpected");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
}
#[test]
@@ -176,14 +120,6 @@ fn server_fill_loose_allows_server_to_remove_fill() {
// ─── Echo behavior ───
#[test]
fn server_fill_echo_reply_decoded_with_fill_configured() {
let mut params = default_params();
params.server_fill = server_fill("rand");
let run = run_one_probe(params, TimestampFields::default());
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
}
// server_fill is a negotiation/config parameter only; it does not affect
// client-observable reply payload layout. The client sends server_fill in
// the open request and expects the server to use it for reply payload
@@ -204,11 +140,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!(
echo_packet_len(false, &run_with.negotiated.params),
test_echo_packet_len(false, &run_with.negotiated.params),
echo_len_with
);
assert_eq!(
echo_packet_len(false, &run_without.negotiated.params),
test_echo_packet_len(false, &run_without.negotiated.params),
echo_len_without
);
}
+8 -5
View File
@@ -10,9 +10,7 @@ use std::{
time::{Duration, SystemTime},
};
use irtt_client::{
Client, ClientConfig, ClientEvent, ClientTimestamp, NegotiatedParams, OpenOutcome, SocketConfig,
};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiatedParams, OpenOutcome, SocketConfig};
use irtt_proto::{
compute_hmac_in_place, echo_packet_len, flags, verify_hmac, Clock, Params, ProtoError,
ReceivedStats, ServerFill, StampAt, TimestampFields, HMAC_SIZE, MAGIC, PROTOCOL_VERSION,
@@ -216,7 +214,7 @@ where
{
let server = start_one_probe_server(server_params, timestamps, hmac_key);
let mut client = Client::connect(build_config(server.addr)).unwrap();
let negotiated = assert_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_started(client.open().unwrap());
let sent_events = client.send_probe().unwrap();
assert_eq!(sent_events.len(), 1);
@@ -522,6 +520,11 @@ fn open_reply(flags: u8, token: u64, params: &Params, hmac_key: Option<&[u8]>) -
packet
}
pub(super) fn test_echo_packet_len(hmac: bool, params: &Params) -> usize {
echo_packet_len(hmac, params)
.expect("managed runner test params must have a non-negative packet length")
}
fn echo_reply_packet(
token: u64,
seq: u32,
@@ -531,7 +534,7 @@ fn echo_reply_packet(
) -> Vec<u8> {
let has_hmac = hmac_key.is_some();
let layout = irtt_proto::PacketLayout::echo(has_hmac, params);
let packet_len = echo_packet_len(has_hmac, params);
let packet_len = test_echo_packet_len(has_hmac, params);
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
@@ -275,61 +275,3 @@ macro_rules! debug_eprintln {
}
use debug_eprintln;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_failure_details_include_bind_command_status_and_output() {
let status = Command::new("sh")
.arg("-c")
.arg("exit 7")
.status()
.expect("shell exits");
let failure = StartupFailure {
bind: "127.0.0.1:12345".to_owned(),
command: "\"irtt\" \"server\"".to_owned(),
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
exit_status: Some(status),
error: Some("readiness timed out or server exited before ready".to_owned()),
};
let details = failure.details();
assert!(details.contains("bind: 127.0.0.1:12345"));
assert!(details.contains("command: \"irtt\" \"server\""));
assert!(details.contains("exit status:"));
assert!(details.contains("address already in use"));
}
#[test]
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
let failures = vec![
StartupFailure {
bind: "127.0.0.1:11111".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
output: "stdout: first".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
StartupFailure {
bind: "127.0.0.1:22222".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
output: "stderr: second".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
];
let message = format_startup_failures("irtt", &failures);
assert!(message.contains("after 2 attempt(s)"));
assert!(message.contains("attempt 1/2"));
assert!(message.contains("attempt 2/2"));
assert!(message.contains("127.0.0.1:11111"));
assert!(message.contains("127.0.0.1:22222"));
assert!(message.contains("stdout: first"));
assert!(message.contains("stderr: second"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-proto"
version = "0.1.1"
version = "0.4.0"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
-14
View File
@@ -31,20 +31,6 @@ mod tests {
use super::*;
use crate::{hmac, FLAG_HMAC};
#[test]
fn close_request_sizes() {
let request = CloseRequest {
token: 0x7896_b6ab_8771_5213,
};
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
assert_eq!(
encode_close_request(&request, Some(b"testkey"))
.unwrap()
.len(),
28
);
}
#[test]
fn hmac_close_request_places_token_after_hmac() {
let packet = encode_close_request(
+4 -4
View File
@@ -1,7 +1,7 @@
use crate::{
flags::{has, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY},
hmac,
layout::{try_echo_packet_len, PacketLayout},
layout::{echo_packet_len, PacketLayout},
open::{check_hmac_presence, reject},
params::Params,
validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, RECV_COUNT_SIZE,
@@ -43,7 +43,7 @@ pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Re
flags |= FLAG_HMAC;
}
let layout = PacketLayout::echo(hmac_key.is_some(), &request.params);
let len = try_echo_packet_len(hmac_key.is_some(), &request.params)?;
let len = echo_packet_len(hmac_key.is_some(), &request.params)?;
let payload_offset = layout.header_len();
let available_payload_len = len.saturating_sub(payload_offset);
if request.payload.len() > available_payload_len {
@@ -87,7 +87,7 @@ pub fn decode_echo_reply(
actual: packet.len(),
});
}
let expected_len = try_echo_packet_len(has(flags, FLAG_HMAC), params)?;
let expected_len = echo_packet_len(has(flags, FLAG_HMAC), params)?;
if packet.len() != expected_len {
return Err(ProtoError::PacketLengthMismatch {
expected: expected_len,
@@ -371,7 +371,7 @@ mod tests {
};
params.received_stats = ReceivedStats::Both;
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(try_echo_packet_len(true, &params).unwrap());
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());
-46
View File
@@ -98,16 +98,6 @@ mod tests {
);
}
#[test]
fn compute_hmac_does_not_mutate_input() {
let packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
let original = packet.clone();
let _digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(packet, original);
}
#[test]
fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
@@ -119,42 +109,6 @@ mod tests {
assert_eq!(digest, reference);
}
#[test]
fn compute_hmac_in_place_does_not_set_hmac_flag() {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&[1, 2, 3, 4]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(packet[3], FLAG_OPEN);
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
.iter()
.any(|byte| *byte != 0));
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
}
#[test]
fn compute_hmac_in_place_only_writes_hmac_field() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let original = packet.clone();
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
assert_ne!(
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
);
assert_eq!(
&packet[hmac_offset() + HMAC_SIZE..],
&original[hmac_offset() + HMAC_SIZE..]
);
}
#[test]
fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
+12 -32
View File
@@ -111,13 +111,7 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize {
PacketLayout::echo(hmac, params).header_len()
}
pub fn echo_packet_len(hmac: bool, params: &Params) -> usize {
let header_len = echo_header_len(hmac, params);
let requested = usize::try_from(params.length).unwrap_or(0);
header_len.max(requested)
}
pub fn try_echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params);
let requested =
usize::try_from(params.length).map_err(|_| ProtoError::NegativePacketLength {
@@ -161,6 +155,12 @@ mod tests {
#[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,
@@ -205,16 +205,6 @@ mod tests {
);
}
#[test]
fn open_and_close_layout_lengths_account_for_hmac() {
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 [
@@ -286,23 +276,13 @@ mod tests {
}
}
#[test]
fn no_timestamp_layout_ignores_clock() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_eq!(
echo_header_len(false, &params(ReceivedStats::None, StampAt::None, clock)),
16
);
}
}
#[test]
fn negotiated_length_never_smaller_than_header() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 20;
assert_eq!(echo_packet_len(false, &p), 60);
assert_eq!(echo_packet_len(false, &p).unwrap(), 60);
p.length = 92;
assert_eq!(echo_packet_len(false, &p), 92);
assert_eq!(echo_packet_len(false, &p).unwrap(), 92);
}
#[test]
@@ -311,7 +291,7 @@ mod tests {
p.length = -1;
assert_eq!(
try_echo_packet_len(false, &p),
echo_packet_len(false, &p),
Err(ProtoError::NegativePacketLength { length: -1 })
);
}
@@ -320,9 +300,9 @@ mod tests {
fn checked_packet_len_preserves_non_negative_header_floor() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 0;
assert_eq!(try_echo_packet_len(false, &p), Ok(60));
assert_eq!(echo_packet_len(false, &p), Ok(60));
p.length = 92;
assert_eq!(try_echo_packet_len(false, &p), Ok(92));
assert_eq!(echo_packet_len(false, &p), Ok(92));
}
}
+20 -2
View File
@@ -1,3 +1,21 @@
//! Low-level wire encode/decode support for the IRTT protocol.
//!
//! `irtt-proto` intentionally mirrors protocol fields closely: request/reply
//! structs expose wire-oriented values, flags, counters, timestamps, and
//! parameter enums rather than higher-level policy decisions. Higher-level
//! crates such as `irtt-client` are responsible for session behavior and
//! user-facing validation.
//!
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
//! `server_fill` values. Direct construction followed by [`Params::encode`]
//! performs no additional validation, so callers that build `Params` manually
//! are responsible for validating those values before sending them.
//!
//! The crate also provides packet layout calculation, open/echo/close packet
//! encoding and decoding, and optional HMAC placement, computation, and
//! verification helpers.
//!
#![forbid(unsafe_code)]
pub mod close;
@@ -15,9 +33,9 @@ pub use echo::{decode_echo_reply, encode_echo_request, EchoReply, EchoRequest, T
pub use error::{ProtoError, Result};
pub use flags::*;
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
pub use layout::{echo_header_len, echo_packet_len, try_echo_packet_len, PacketLayout};
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
pub const PROTOCOL_VERSION: i64 = 1;
+57 -7
View File
@@ -1,5 +1,16 @@
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
/// Protocol compatibility bound for an encoded `server_fill` parameter.
pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// Low-level wire representation of IRTT open parameters.
///
/// `Params` mirrors the protocol fields closely and can be constructed
/// directly by callers. Direct construction can therefore produce values that
/// should not be sent on the wire, such as an oversized `server_fill` value.
/// Higher-level callers should validate user or configuration input before
/// encoding. The normal `irtt-client` configuration path enforces
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -21,6 +32,10 @@ impl Params {
}
}
/// Encodes these parameters without performing additional validation.
///
/// Callers that construct `Params` directly are responsible for validating
/// user or configuration input before encoding.
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
push_int(1, self.protocol_version, &mut out);
@@ -39,6 +54,10 @@ impl Params {
out
}
/// Decodes parameters and rejects malformed or incompatible incoming values.
///
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
/// values longer than [`MAX_SERVER_FILL_BYTES`].
pub fn decode(input: &[u8]) -> Result<Self> {
let mut params = Self::default();
let mut pos = 0;
@@ -59,6 +78,12 @@ impl Params {
pos += used;
let len = usize::try_from(len)
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
if len > MAX_SERVER_FILL_BYTES {
return Err(ProtoError::ParameterLengthTooLarge {
tag,
length: len as u64,
});
}
if input.len().saturating_sub(pos) < len {
return Err(ProtoError::MalformedParams);
}
@@ -206,6 +231,14 @@ mod tests {
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
}
#[test]
fn params_round_trip() {
let params = Params {
@@ -225,7 +258,7 @@ mod tests {
}
#[test]
fn params_round_trip_received_stats_modes() {
fn params_round_trip_negotiated_option_modes() {
for received_stats in [
ReceivedStats::None,
ReceivedStats::Count,
@@ -238,10 +271,7 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_timestamp_modes() {
for stamp_at in [
StampAt::None,
StampAt::Send,
@@ -256,10 +286,7 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_clock_modes() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params {
protocol_version: 1,
@@ -323,6 +350,29 @@ mod tests {
});
}
#[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 {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-stats"
version = "0.1.1"
version = "0.4.0"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
+258
View File
@@ -0,0 +1,258 @@
use std::time::Duration;
use crate::ipdv::{IpdvSample, IpdvTracker};
use crate::loss::loss_stats;
use crate::normalization::{ReplySample, StatsEvent};
use crate::time_stats::TimeMetric;
use crate::{
EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, OneWayDelayStats, PacketCounts,
RttStats, SampleMode, ServerProcessingStats, Snapshot,
};
const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CoreStats {
events: EventCounts,
packets: PacketCounts,
send_call: TimeMetric,
timer_error: TimeMetric,
rtt_primary: TimeMetric,
rtt_raw: TimeMetric,
rtt_adjusted: TimeMetric,
ipdv_round_trip: TimeMetric,
ipdv_send: TimeMetric,
ipdv_receive: TimeMetric,
send_delay: TimeMetric,
receive_delay: TimeMetric,
server_processing: TimeMetric,
ipdv_tracker: IpdvTracker,
}
impl CoreStats {
pub(crate) fn new(sample_mode: SampleMode) -> Self {
let sequence_limit = if sample_mode == SampleMode::Exact {
None
} else {
Some(CONTINUOUS_SEQUENCE_LIMIT)
};
Self {
events: EventCounts::default(),
packets: PacketCounts::default(),
send_call: TimeMetric::new(false),
timer_error: TimeMetric::new(false),
rtt_primary: TimeMetric::new(sample_mode == SampleMode::Exact),
rtt_raw: TimeMetric::new(sample_mode == SampleMode::Exact),
rtt_adjusted: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_round_trip: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_send: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_receive: TimeMetric::new(sample_mode == SampleMode::Exact),
send_delay: TimeMetric::new(sample_mode == SampleMode::Exact),
receive_delay: TimeMetric::new(sample_mode == SampleMode::Exact),
server_processing: TimeMetric::new(false),
ipdv_tracker: IpdvTracker::new(sequence_limit),
}
}
pub(crate) fn apply(&mut self, event: StatsEvent) -> EventStatsUpdate {
match event {
StatsEvent::Sent {
bytes,
send_call_ns,
timer_error_ns,
..
} => {
self.apply_sent(bytes, send_call_ns, timer_error_ns);
EventStatsUpdate::default()
}
StatsEvent::UniqueReply {
is_late, sample, ..
} => self.apply_unique_reply(is_late, *sample),
StatsEvent::DuplicateReply { bytes, .. } => {
self.apply_duplicate_reply(bytes);
EventStatsUpdate::default()
}
StatsEvent::Loss { .. } => {
self.apply_loss();
EventStatsUpdate::default()
}
StatsEvent::Warning { .. } => {
self.apply_warning();
EventStatsUpdate::default()
}
StatsEvent::UntrackedLate { bytes, .. } => {
self.apply_untracked_late(bytes);
EventStatsUpdate::default()
}
}
}
fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: i128) {
self.events.sent_events += 1;
self.packets.packets_sent += 1;
self.packets.bytes_sent = self.packets.bytes_sent.saturating_add(bytes as u64);
self.send_call.push_ns(send_call_ns);
self.timer_error.push_ns(timer_error_ns);
}
fn apply_unique_reply(&mut self, is_late: bool, sample: ReplySample) -> EventStatsUpdate {
let mut update = EventStatsUpdate {
contributed_sample: true,
..EventStatsUpdate::default()
};
self.account_unique_reply(is_late, &sample);
self.record_reply_metrics(&sample);
update.ipdv_pairs = self.apply_ipdv_sample(sample.ipdv);
update
}
fn account_unique_reply(&mut self, is_late: bool, sample: &ReplySample) {
self.events.echo_replies += u64::from(!is_late);
self.events.late_unique_replies += u64::from(is_late);
self.packets.packets_received += 1;
self.packets.unique_replies += 1;
self.packets.late_packets += u64::from(is_late);
self.packets.bytes_received = self
.packets
.bytes_received
.saturating_add(sample.bytes as u64);
if let Some(count) = sample.received_count {
let count = u64::from(count);
self.packets.server_packets_received = Some(
self.packets
.server_packets_received
.map_or(count, |current| current.max(count)),
);
}
if let Some(window) = sample.received_window {
self.packets.server_received_window = Some(window);
}
}
fn record_reply_metrics(&mut self, sample: &ReplySample) {
self.rtt_primary.push_ns(sample.ipdv.rtt_primary_ns);
self.rtt_raw.push_ns(sample.rtt_raw_ns);
if let Some(adjusted) = sample.rtt_adjusted_ns {
self.rtt_adjusted.push_ns(adjusted);
}
if let Some(processing) = sample.server_processing_ns {
self.server_processing.push_ns(processing);
}
if let Some(delay) = sample.send_delay_ns {
self.send_delay.push_ns(delay);
}
if let Some(delay) = sample.receive_delay_ns {
self.receive_delay.push_ns(delay);
}
}
fn apply_ipdv_sample(&mut self, sample: IpdvSample) -> Vec<IpdvPairUpdate> {
let mut updates = Vec::new();
for pair in self.ipdv_tracker.insert(sample) {
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(pair.rtt_ipdv_ns) else {
continue;
};
let send_ipdv = pair
.send_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
let receive_ipdv = pair
.receive_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
self.ipdv_round_trip.push_ns(pair.rtt_ipdv_ns);
if let Some(value) = pair.send_ipdv_ns {
self.ipdv_send.push_ns(value);
}
if let Some(value) = pair.receive_ipdv_ns {
self.ipdv_receive.push_ns(value);
}
updates.push(IpdvPairUpdate {
previous_seq: pair.previous_seq,
current_seq: pair.current_seq,
rtt_ipdv,
send_ipdv,
receive_ipdv,
});
}
updates
}
fn apply_duplicate_reply(&mut self, bytes: usize) {
self.events.duplicate_replies += 1;
self.packets.packets_received += 1;
self.packets.duplicates += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
}
fn apply_loss(&mut self) {
self.events.loss_events += 1;
}
fn apply_warning(&mut self) {
self.events.warning_events += 1;
}
fn apply_untracked_late(&mut self, bytes: usize) {
self.events.untracked_late_replies += 1;
self.packets.packets_received += 1;
self.packets.late_packets += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
}
pub(crate) fn snapshot(&self) -> Snapshot {
let packets = self.packets;
Snapshot {
events: self.events,
packets,
loss: loss_stats(packets),
send_call: self.send_call.stats(),
timer_error: self.timer_error.stats(),
rtt: self.rtt_stats(),
ipdv: self.ipdv_stats(),
one_way_delay: self.one_way_delay_stats(),
server_processing: self.server_processing_stats(),
}
}
fn rtt_stats(&self) -> RttStats {
RttStats {
primary: self.rtt_primary.stats(),
raw: self.rtt_raw.stats(),
adjusted: self.rtt_adjusted.stats(),
}
}
fn ipdv_stats(&self) -> IpdvStats {
IpdvStats {
round_trip: self.ipdv_round_trip.stats(),
send: self.ipdv_send.stats(),
receive: self.ipdv_receive.stats(),
}
}
fn one_way_delay_stats(&self) -> OneWayDelayStats {
OneWayDelayStats {
send_delay: self.send_delay.stats(),
receive_delay: self.receive_delay.stats(),
}
}
fn server_processing_stats(&self) -> ServerProcessingStats {
ServerProcessingStats {
processing: self.server_processing.stats(),
}
}
}
fn duration_from_non_negative_i128_ns(value: i128) -> Option<Duration> {
u64::try_from(value).ok().map(Duration::from_nanos)
}
+305
View File
@@ -0,0 +1,305 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
time::{Duration, Instant},
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct IpdvSample {
pub(crate) seq: u32,
pub(crate) rtt_primary_ns: i128,
pub(crate) client_send_mono: Instant,
pub(crate) client_receive_mono: Instant,
pub(crate) client_send_wall_ns: Option<i128>,
pub(crate) client_receive_wall_ns: Option<i128>,
pub(crate) server_receive_mono_ns: Option<i64>,
pub(crate) server_send_mono_ns: Option<i64>,
pub(crate) server_receive_wall_ns: Option<i64>,
pub(crate) server_send_wall_ns: Option<i64>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct IpdvTracker {
samples: HashMap<u32, IpdvSample>,
sample_order: VecDeque<u32>,
completed_pairs: HashSet<u32>,
sequence_limit: Option<usize>,
}
impl IpdvTracker {
pub(crate) fn new(sequence_limit: Option<usize>) -> Self {
Self {
samples: HashMap::new(),
sample_order: VecDeque::new(),
completed_pairs: HashSet::new(),
sequence_limit,
}
}
pub(crate) fn insert(&mut self, sample: IpdvSample) -> Vec<CompletedIpdvPair> {
let seq = sample.seq;
if self.samples.insert(seq, sample).is_some() {
return Vec::new();
}
self.sample_order.push_back(seq);
self.enforce_sequence_limit();
let mut pairs = Vec::with_capacity(2);
if let Some(pair) = self.try_pair(seq) {
pairs.push(pair);
}
if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
pairs.push(pair);
}
pairs
}
fn enforce_sequence_limit(&mut self) {
let Some(limit) = self.sequence_limit else {
return;
};
while self.samples.len() > limit {
let Some(seq) = self.sample_order.pop_front() else {
break;
};
if self.samples.remove(&seq).is_some() {
self.completed_pairs.remove(&seq);
self.completed_pairs.remove(&seq.wrapping_add(1));
}
}
}
fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> {
let previous_seq = current_seq.wrapping_sub(1);
if !self.completed_pairs.insert(current_seq) {
return None;
}
let Some(previous) = self.samples.get(&previous_seq) else {
self.completed_pairs.remove(&current_seq);
return None;
};
let Some(current) = self.samples.get(&current_seq) else {
self.completed_pairs.remove(&current_seq);
return None;
};
Some(CompletedIpdvPair {
previous_seq,
current_seq,
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns),
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns),
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CompletedIpdvPair {
pub(crate) previous_seq: u32,
pub(crate) current_seq: u32,
pub(crate) rtt_ipdv_ns: i128,
pub(crate) send_ipdv_ns: Option<i128>,
pub(crate) receive_ipdv_ns: Option<i128>,
}
fn send_ipdv_ns(previous: &IpdvSample, current: &IpdvSample) -> Option<i128> {
if let (Some(prev_server), Some(cur_server)) = (
previous.server_receive_mono_ns,
current.server_receive_mono_ns,
) {
return Some(
i128::from(cur_server)
- i128::from(prev_server)
- instant_diff_ns(current.client_send_mono, previous.client_send_mono),
);
}
if let (Some(prev_server), Some(cur_server), Some(prev_client), Some(cur_client)) = (
previous.server_receive_wall_ns,
current.server_receive_wall_ns,
previous.client_send_wall_ns,
current.client_send_wall_ns,
) {
return Some(i128::from(cur_server) - i128::from(prev_server) - (cur_client - prev_client));
}
None
}
fn receive_ipdv_ns(previous: &IpdvSample, current: &IpdvSample) -> Option<i128> {
if let (Some(prev_server), Some(cur_server)) =
(previous.server_send_mono_ns, current.server_send_mono_ns)
{
return Some(
instant_diff_ns(current.client_receive_mono, previous.client_receive_mono)
- (i128::from(cur_server) - i128::from(prev_server)),
);
}
if let (Some(prev_server), Some(cur_server), Some(prev_client), Some(cur_client)) = (
previous.server_send_wall_ns,
current.server_send_wall_ns,
previous.client_receive_wall_ns,
current.client_receive_wall_ns,
) {
return Some(
(cur_client - prev_client) - (i128::from(cur_server) - i128::from(prev_server)),
);
}
None
}
fn instant_diff_ns(current: Instant, previous: Instant) -> i128 {
if let Some(diff) = current.checked_duration_since(previous) {
duration_ns_i128(diff)
} else {
-duration_ns_i128(previous.duration_since(current))
}
}
fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
}
fn abs_i128_ns(value: i128) -> i128 {
value.saturating_abs()
}
#[cfg(test)]
mod tests {
use super::*;
fn ipdv_sample(seq: u32, rtt_primary_ns: i128) -> IpdvSample {
let now = Instant::now();
IpdvSample {
seq,
rtt_primary_ns,
client_send_mono: now,
client_receive_mono: now,
client_send_wall_ns: None,
client_receive_wall_ns: None,
server_receive_mono_ns: None,
server_send_mono_ns: None,
server_receive_wall_ns: None,
server_send_wall_ns: None,
}
}
#[test]
fn ipdv_tracker_completes_adjacent_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
let pairs = tracker.insert(ipdv_sample(1, 14));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: 0,
current_seq: 1,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_gap_fill_completes_both_adjacent_pairs() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert!(tracker.insert(ipdv_sample(2, 20)).is_empty());
let pairs = tracker.insert(ipdv_sample(1, 13));
assert_eq!(
pairs,
vec![
CompletedIpdvPair {
previous_seq: 0,
current_seq: 1,
rtt_ipdv_ns: 3,
send_ipdv_ns: None,
receive_ipdv_ns: None,
},
CompletedIpdvPair {
previous_seq: 1,
current_seq: 2,
rtt_ipdv_ns: 7,
send_ipdv_ns: None,
receive_ipdv_ns: None,
},
]
);
}
#[test]
fn ipdv_tracker_completes_wrapped_adjacent_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX, 10)).is_empty());
let pairs = tracker.insert(ipdv_sample(0, 14));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_wrap_gap_does_not_complete_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX - 1, 10)).is_empty());
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
}
#[test]
fn ipdv_tracker_late_wrapped_previous_completes_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
let pairs = tracker.insert(ipdv_sample(u32::MAX, 10));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert_eq!(tracker.insert(ipdv_sample(1, 14)).len(), 1);
assert!(tracker.insert(ipdv_sample(1, 18)).is_empty());
}
#[test]
fn ipdv_tracker_bounded_mode_evicts_old_sequence_state() {
let mut tracker = IpdvTracker::new(Some(2));
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert_eq!(tracker.insert(ipdv_sample(1, 14)).len(), 1);
let pairs = tracker.insert(ipdv_sample(2, 20));
assert_eq!(pairs.len(), 1);
assert!(!tracker.samples.contains_key(&0));
assert!(!tracker.completed_pairs.contains(&0));
assert!(!tracker.completed_pairs.contains(&1));
assert!(tracker.completed_pairs.contains(&2));
}
}
+146 -1333
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
use crate::PacketCounts;
#[derive(Debug, Clone, Copy, PartialEq)]
/// Packet loss, duplicate, and late-packet statistics.
///
/// Percentage fields are percentages from `0.0` to `100.0` in normal cases.
/// Directional upstream and downstream loss can be negative when
/// server-reported counts exceed local expectations.
pub struct LossStats {
/// Locally inferred total lost packets.
pub lost_packets: u64,
/// Server-assisted upstream loss estimate, when server counts are available.
pub upstream_loss_packets: Option<i128>,
/// Server-assisted downstream loss estimate, when server counts are available.
pub downstream_loss_packets: Option<i128>,
/// Total packet loss percentage.
pub packet_loss_percent: f64,
/// Server-assisted upstream loss percentage.
pub upstream_loss_percent: f64,
/// Server-assisted downstream loss percentage.
pub downstream_loss_percent: f64,
/// Duplicate reply percentage.
pub duplicate_percent: f64,
/// Late reply packet percentage.
pub late_packets_percent: f64,
}
pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
let lost = packets.packets_sent.saturating_sub(packets.unique_replies);
let packet_loss_percent = if packets.packets_sent == 0 {
0.0
} else if packets.unique_replies == 0 {
100.0
} else {
percent(lost as f64, packets.packets_sent as f64)
};
let (
upstream_loss_packets,
upstream_loss_percent,
downstream_loss_packets,
downstream_loss_percent,
) = if let Some(server_received) = packets.server_packets_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 {
0.0
} else {
percent(upstream as f64, packets.packets_sent as f64)
};
let downstream_percent = if server_received == 0 {
0.0
} else {
percent(downstream as f64, server_received as f64)
};
(
Some(upstream),
upstream_percent,
Some(downstream),
downstream_percent,
)
} else {
(None, 0.0, None, 0.0)
};
LossStats {
lost_packets: lost,
upstream_loss_packets,
downstream_loss_packets,
packet_loss_percent,
upstream_loss_percent,
downstream_loss_percent,
duplicate_percent: if packets.packets_received == 0 {
0.0
} else {
percent(packets.duplicates as f64, packets.packets_received as f64)
},
late_packets_percent: if packets.packets_received == 0 {
0.0
} else {
percent(packets.late_packets as f64, packets.packets_received as f64)
},
}
}
fn percent(numerator: f64, denominator: f64) -> f64 {
100.0 * numerator / denominator
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_and_late_percentages_use_packets_received_denominator() {
let loss = loss_stats(PacketCounts {
packets_received: 4,
duplicates: 1,
late_packets: 2,
..PacketCounts::default()
});
assert_eq!(loss.duplicate_percent, 25.0);
assert_eq!(loss.late_packets_percent, 50.0);
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, ReceivedStatsSample, RttSample, ServerTiming,
SignedDuration,
};
use crate::ipdv::IpdvSample;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum StatsEvent {
Sent {
at: Instant,
bytes: usize,
send_call_ns: i128,
timer_error_ns: i128,
},
UniqueReply {
at: Instant,
is_late: bool,
sample: Box<ReplySample>,
},
DuplicateReply {
at: Instant,
bytes: usize,
},
Loss {
at: Instant,
},
Warning {
at: Instant,
},
UntrackedLate {
at: Instant,
bytes: usize,
},
}
pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
match event {
ClientEvent::EchoSent {
sent_at,
bytes,
send_call,
timer_error,
..
} => Some(StatsEvent::Sent {
at: sent_at.mono,
bytes: *bytes,
send_call_ns: duration_ns_i128(*send_call),
timer_error_ns: duration_ns_i128(*timer_error),
}),
ClientEvent::EchoReply {
seq,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes,
..
} => Some(StatsEvent::UniqueReply {
at: received_at.mono,
is_late: false,
sample: Box::new(ReplySample::from_reply_parts(
*seq,
*sent_at,
*received_at,
*rtt,
*server_timing,
*one_way,
*received_stats,
*bytes,
)),
}),
ClientEvent::LateReply {
seq,
sent_at: Some(sent_at),
received_at,
rtt: Some(rtt),
server_timing,
one_way,
received_stats,
bytes,
..
} => Some(StatsEvent::UniqueReply {
at: received_at.mono,
is_late: true,
sample: Box::new(ReplySample::from_reply_parts(
*seq,
*sent_at,
*received_at,
*rtt,
*server_timing,
*one_way,
*received_stats,
*bytes,
)),
}),
ClientEvent::LateReply {
received_at, bytes, ..
} => Some(StatsEvent::UntrackedLate {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::DuplicateReply {
received_at, bytes, ..
} => Some(StatsEvent::DuplicateReply {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
ClientEvent::SessionStarted { .. }
| ClientEvent::NoTestCompleted { .. }
| ClientEvent::SessionClosed { .. } => None,
}
}
impl StatsEvent {
pub(crate) fn at(&self) -> Instant {
match self {
Self::Sent { at, .. }
| Self::UniqueReply { at, .. }
| Self::DuplicateReply { at, .. }
| Self::Loss { at }
| Self::Warning { at }
| Self::UntrackedLate { at, .. } => *at,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ReplySample {
pub(crate) bytes: usize,
pub(crate) rtt_raw_ns: i128,
pub(crate) rtt_adjusted_ns: Option<i128>,
pub(crate) send_delay_ns: Option<i128>,
pub(crate) receive_delay_ns: Option<i128>,
pub(crate) server_processing_ns: Option<i128>,
pub(crate) received_count: Option<u32>,
pub(crate) received_window: Option<u64>,
pub(crate) ipdv: IpdvSample,
}
impl ReplySample {
#[allow(clippy::too_many_arguments)]
fn from_reply_parts(
seq: u32,
sent_at: ClientTimestamp,
received_at: ClientTimestamp,
rtt: RttSample,
server_timing: Option<ServerTiming>,
one_way: Option<OneWayDelaySample>,
received_stats: Option<ReceivedStatsSample>,
bytes: usize,
) -> Self {
let rtt_primary_ns = signed_duration_ns(rtt.effective);
Self {
bytes,
rtt_raw_ns: duration_ns_i128(rtt.raw),
rtt_adjusted_ns: rtt.adjusted.map(signed_duration_ns),
send_delay_ns: one_way
.and_then(|sample| sample.client_to_server)
.map(signed_duration_ns),
receive_delay_ns: one_way
.and_then(|sample| sample.server_to_client)
.map(signed_duration_ns),
server_processing_ns: server_timing
.and_then(|timing| timing.processing)
.map(duration_ns_i128),
received_count: received_stats.and_then(|stats| stats.count),
received_window: received_stats.and_then(|stats| stats.window),
ipdv: IpdvSample {
seq,
rtt_primary_ns,
client_send_mono: sent_at.mono,
client_receive_mono: received_at.mono,
client_send_wall_ns: system_time_ns(sent_at.wall),
client_receive_wall_ns: system_time_ns(received_at.wall),
server_receive_mono_ns: server_timing.and_then(|timing| timing.receive_mono_ns),
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns),
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns),
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns),
},
}
}
}
fn system_time_ns(time: SystemTime) -> Option<i128> {
if let Ok(duration) = time.duration_since(UNIX_EPOCH) {
return Some(duration_ns_i128(duration));
}
UNIX_EPOCH
.duration_since(time)
.ok()
.map(|duration| -duration_ns_i128(duration))
}
fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
}
fn signed_duration_ns(duration: SignedDuration) -> i128 {
duration.as_nanos()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_time_before_epoch_is_supported() {
let before = UNIX_EPOCH - Duration::from_nanos(7);
assert_eq!(system_time_ns(before), Some(-7));
let after = UNIX_EPOCH + Duration::from_nanos(7);
assert_eq!(system_time_ns(after), Some(7));
let now = SystemTime::now();
assert!(system_time_ns(now).is_some());
}
}
+57
View File
@@ -0,0 +1,57 @@
use std::{collections::VecDeque, time::Duration};
use crate::{core::CoreStats, normalization::StatsEvent, SampleMode, Snapshot, StatsConfig};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RollingEvents {
count_limit: Option<usize>,
time_limit: Option<Duration>,
count_events: Option<VecDeque<StatsEvent>>,
time_events: Option<VecDeque<StatsEvent>>,
}
impl RollingEvents {
pub(crate) fn new(config: StatsConfig) -> Self {
Self {
count_limit: config.rolling_count,
time_limit: config.rolling_time,
count_events: config.rolling_count.map(|_| VecDeque::new()),
time_events: config.rolling_time.map(|_| VecDeque::new()),
}
}
pub(crate) fn push(&mut self, event: StatsEvent) {
if let (Some(limit), Some(window)) = (self.count_limit, self.count_events.as_mut()) {
window.push_back(event.clone());
while window.len() > limit {
window.pop_front();
}
}
if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) {
let cutoff = event.at().checked_sub(duration);
window.push_back(event);
if let Some(cutoff) = cutoff {
while window.front().is_some_and(|event| event.at() < cutoff) {
window.pop_front();
}
}
}
}
pub(crate) fn count_snapshot(&self) -> Option<Snapshot> {
self.count_events.as_ref().map(snapshot_window)
}
pub(crate) fn time_snapshot(&self) -> Option<Snapshot> {
self.time_events.as_ref().map(snapshot_window)
}
}
fn snapshot_window(events: &VecDeque<StatsEvent>) -> Snapshot {
let mut core = CoreStats::new(SampleMode::RunningOnly);
for event in events {
core.apply(event.clone());
}
core.snapshot()
}
+157
View File
@@ -0,0 +1,157 @@
#[derive(Debug, Clone, Copy, PartialEq)]
/// Summary statistics for timing values.
///
/// All fields use nanoseconds except `variance_ns2`, which is nanoseconds
/// squared. Values may represent signed timing quantities such as scheduling
/// error or one-way delay.
pub struct TimeStats {
/// Number of samples included in this summary.
pub count: u64,
/// Sum of all samples, in nanoseconds.
pub total_ns: i128,
/// Smallest sample, in nanoseconds.
pub min_ns: Option<i128>,
/// Largest sample, in nanoseconds.
pub max_ns: Option<i128>,
/// Arithmetic mean, in nanoseconds.
pub mean_ns: f64,
/// Median, in nanoseconds, when exact samples were retained.
pub median_ns: Option<f64>,
/// Sample variance, in nanoseconds squared.
pub variance_ns2: f64,
}
impl TimeStats {
/// Returns the sample standard deviation, in nanoseconds.
pub fn stddev_ns(&self) -> f64 {
self.variance_ns2.sqrt()
}
/// Returns whether this summary contains no samples.
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TimeMetric {
running: RunningStats,
samples: Option<Vec<i128>>,
}
impl TimeMetric {
pub(crate) fn new(retain_samples: bool) -> Self {
Self {
running: RunningStats::default(),
samples: retain_samples.then(Vec::new),
}
}
pub(crate) fn push_ns(&mut self, value: i128) {
self.running.push(value);
if let Some(samples) = self.samples.as_mut() {
samples.push(value);
}
}
pub(crate) fn stats(&self) -> TimeStats {
self.running
.stats(self.samples.as_ref().and_then(|samples| median_ns(samples)))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
struct RunningStats {
count: u64,
total_ns: i128,
min_ns: Option<i128>,
max_ns: Option<i128>,
mean_ns: f64,
m2_ns2: f64,
}
impl RunningStats {
fn push(&mut self, value: i128) {
self.count += 1;
self.total_ns = self.total_ns.saturating_add(value);
self.min_ns = Some(self.min_ns.map_or(value, |min| min.min(value)));
self.max_ns = Some(self.max_ns.map_or(value, |max| max.max(value)));
let x = value as f64;
let delta = x - self.mean_ns;
self.mean_ns += delta / self.count as f64;
let delta2 = x - self.mean_ns;
self.m2_ns2 += delta * delta2;
}
fn stats(&self, median_ns: Option<f64>) -> TimeStats {
TimeStats {
count: self.count,
total_ns: self.total_ns,
min_ns: self.min_ns,
max_ns: self.max_ns,
mean_ns: if self.count == 0 { 0.0 } else { self.mean_ns },
median_ns,
variance_ns2: sample_variance(self.count, self.m2_ns2),
}
}
}
fn sample_variance(count: u64, m2: f64) -> f64 {
if count < 2 {
0.0
} else {
m2 / (count - 1) as f64
}
}
fn median_ns(samples: &[i128]) -> Option<f64> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let mid = sorted.len() / 2;
Some(if sorted.len() % 2 == 1 {
sorted[mid] as f64
} else {
(sorted[mid - 1] as f64 + sorted[mid] as f64) / 2.0
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn running_duration_stats_use_sample_variance() {
let mut metric = TimeMetric::new(false);
metric.push_ns(1);
metric.push_ns(2);
metric.push_ns(3);
let stats = metric.stats();
assert_eq!(stats.count, 3);
assert_eq!(stats.total_ns, 6);
assert_eq!(stats.min_ns, Some(1));
assert_eq!(stats.max_ns, Some(3));
assert_eq!(stats.mean_ns, 2.0);
assert_eq!(stats.variance_ns2, 1.0);
assert_eq!(stats.stddev_ns(), 1.0);
}
#[test]
fn exact_median_handles_odd_and_even_samples() {
assert_eq!(median_ns(&[3, 1, 2]), Some(2.0));
assert_eq!(median_ns(&[4, 1, 2, 3]), Some(2.5));
assert_eq!(median_ns(&[-5, 1, 3]), Some(1.0));
assert_eq!(median_ns(&[-5, 1, 3, 7]), Some(2.0));
}
#[test]
fn single_sample_stddev_is_zero() {
let mut metric = TimeMetric::new(false);
metric.push_ns(42);
let stats = metric.stats();
assert_eq!(stats.variance_ns2, 0.0);
assert_eq!(stats.stddev_ns(), 0.0);
}
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more