Compare commits

...
44 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
48 changed files with 9417 additions and 2094 deletions

No files matched your search

+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
@@ -56,11 +56,11 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with: with:
persona: regular persona: regular
+10 -10
View File
@@ -24,12 +24,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: rustfmt components: rustfmt
cache-key: fmt cache-key: fmt
@@ -44,12 +44,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: clippy components: clippy
cache-key: clippy cache-key: clippy
@@ -79,12 +79,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache-key: check cache-key: check
@@ -102,12 +102,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache-key: test cache-key: test
@@ -136,12 +136,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache-key: docs cache-key: docs
+2 -2
View File
@@ -25,12 +25,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache-key: interop-netem cache-key: interop-netem
+3 -3
View File
@@ -24,12 +24,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: clippy components: clippy
cache-key: clippy-sarif cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
@@ -56,7 +56,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
Generated
+1200 -173
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -10,15 +10,15 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
rust-version = "1.75" rust-version = "1.85"
repository = "https://github.com/Lochnair/irtt-rs" repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs" homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md" readme = "README.md"
[workspace.dependencies] [workspace.dependencies]
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" } irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.3.0", path = "crates/irtt-client" } irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" } irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
+113 -27
View File
@@ -15,7 +15,7 @@ This repository currently provides:
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature - `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 - `irtt-client`, a Rust library for running client sessions and consuming events
- finite and continuous probe runs - finite and continuous probe runs
- human, simple, machine-readable, and RTT-only stream output modes - table, CSV, TSV, and JSON Lines event-row output formats with selectable columns
- optional local summary statistics - optional local summary statistics
Server support is not implemented. Server support is not implemented.
@@ -24,7 +24,7 @@ confusing overlap with upstream IRTT.
## Install ## Install
Requires Rust 1.75 or newer. Requires Rust 1.85 or newer.
From a local checkout: From a local checkout:
@@ -44,13 +44,37 @@ cargo install --path crates/irtt-cli --features tui
Or run it directly without installing: Or run it directly without installing:
```sh ```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 For space-sensitive packaging, such as OpenWrt packages, distributors can ship
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`, only `irtt-rs` and symlink or hardlink enabled applet names such as `irtt-cli`
`irtt-tui`, and future server applet names to it. Applet dispatch is based on and, when built with the `tui` feature, `irtt-tui` to it. Applet dispatch is
the invoked binary name. 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 ## CLI usage
@@ -72,13 +96,29 @@ Run continuously:
irtt-cli <server> --duration 0 irtt-cli <server> --duration 0
``` ```
Use a specific output mode: Probe multiple targets concurrently:
```sh ```sh
irtt-cli <server> --output human irtt-cli host-a:2112 host-b:2112
irtt-cli <server> --output simple irtt-cli --target ams=ams.example.com:2112 --target sg=sg.example.com:2112
irtt-cli <server> --output machine irtt-cli host-a:2112 host-b:2112 --pacing burst
irtt-cli <server> --output rtt-us ```
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 With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
@@ -89,6 +129,8 @@ session gracefully.
```sh ```sh
irtt-tui <server> irtt-tui <server>
irtt-tui <server> --duration 30s 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> cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
``` ```
@@ -96,37 +138,81 @@ For available options:
```sh ```sh
irtt-cli --help irtt-cli --help
irtt-tui --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: Example:
```sh ```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 ```json
event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none {"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 fields are reported in microseconds. `raw_rtt_us` is the measured client RTT, send-call, and timer-error `*_us` fields are reported in microseconds.
send-to-receive RTT. `effective_rtt_us` and `adjusted_rtt_us` are signed and may `raw_rtt_us` is the measured client send-to-receive RTT. `effective_rtt_us` and
be negative when server processing exceeds the raw RTT or timing correction `adjusted_rtt_us` are signed and may be negative when server processing exceeds
produces a negative adjusted value. When one-way delay fields are present, the raw RTT or timing correction produces a negative adjusted value. When
`client_to_server_us` and `server_to_client_us` are also signed microseconds and one-way delay fields are present, `sd_us` and `rd_us` are also signed
may be negative due to wall-clock skew. 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 ```sh
irtt-cli <server> --output rtt-us irtt-cli <server> --format tsv --columns effective_rtt_us --header never
``` ```
## Library usage ## Library usage
+11 -9
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-cli" name = "irtt-cli"
version = "0.3.0" version = "0.4.0"
description = "Command-line IRTT-compatible client" description = "Command-line IRTT-compatible client"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -16,29 +16,31 @@ doc = false
[[bin]] [[bin]]
name = "irtt-rs" name = "irtt-rs"
path = "src/main.rs" path = "src/bin/irtt-rs.rs"
[[bin]] [[bin]]
name = "irtt-cli" name = "irtt-cli"
path = "src/main.rs" path = "src/bin/irtt-cli.rs"
required-features = ["client"] required-features = ["client"]
[[bin]] [[bin]]
name = "irtt-tui" name = "irtt-tui"
path = "src/main.rs" path = "src/bin/irtt-tui.rs"
required-features = ["tui"] required-features = ["tui"]
[features] [features]
default = ["client", "stats"] default = ["client", "stats"]
client = ["dep:irtt-client", "dep:irtt-proto"] client-runtime = ["dep:irtt-client", "dep:irtt-proto"]
stats = ["client", "dep:irtt-stats"] client = ["client-runtime"]
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"] stats = ["client-runtime", "dep:irtt-stats"]
tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"]
full = ["client", "stats", "tui"]
[dependencies] [dependencies]
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.28.1", optional = true } crossterm = { version = "0.29.0", optional = true }
ctrlc = "3.5.2" ctrlc = "3.5.2"
irtt-client = { workspace = true, optional = true } irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true } irtt-proto = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true } irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.29.0", optional = true } ratatui = { version = "0.30.2", optional = true }
+169 -32
View File
@@ -1,4 +1,6 @@
use std::path::Path; use std::{ffi::OsString, path::Path};
use clap::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet { pub enum RequestedApplet {
@@ -7,20 +9,17 @@ pub enum RequestedApplet {
Server, Server,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppletAvailability { pub enum AppletDispatch {
Client, Run {
Tui, applet: RequestedApplet,
Server, argv: Vec<OsString>,
None, },
Help(String),
} }
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> { pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match Path::new(argv0) match applet_basename(argv0) {
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0)
{
"irtt-cli" => Some(RequestedApplet::Client), "irtt-cli" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui), "irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server), "irttd" | "irtt-server" => Some(RequestedApplet::Server),
@@ -29,20 +28,110 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
} }
} }
pub const fn default_applet_for_features( pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
client_enabled: bool, let argv0 = argv
tui_enabled: bool, .first()
server_enabled: bool, .and_then(|arg| arg.to_str())
) -> AppletAvailability { .unwrap_or("irtt-rs");
if client_enabled {
AppletAvailability::Client if let Some(applet) = detect_applet_from_argv0(argv0) {
} else if tui_enabled { return Ok(AppletDispatch::Run { applet, argv });
AppletAvailability::Tui
} else if server_enabled {
AppletAvailability::Server
} else {
AppletAvailability::None
} }
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)] #[cfg(test)]
@@ -68,18 +157,66 @@ mod tests {
} }
#[test] #[test]
fn default_applet_prefers_client_then_tui() { 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!( assert_eq!(
default_applet_for_features(true, true, false), dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
AppletAvailability::Client .unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: vec!["irtt-cli".into(), "host:2112".into()],
}
); );
assert_eq!( assert_eq!(
default_applet_for_features(false, true, false), dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
AppletAvailability::Tui AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: vec!["irtt-tui".into(), "host:2112".into()],
}
); );
assert_eq!( assert_eq!(
default_applet_for_features(false, false, false), dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
AppletAvailability::None 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()
}
+214 -31
View File
@@ -3,15 +3,41 @@ use std::time::Duration;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig; use irtt_client::ClientConfig;
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg}; 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); pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")] #[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs { pub struct ClientArgs {
/// Server address or host, with optional port. /// Server address or host, with optional port. Repeat for multi-target mode.
pub server: String, #[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( #[arg(
long, long,
@@ -25,18 +51,52 @@ pub struct ClientArgs {
#[command(flatten)] #[command(flatten)]
pub common: CommonClientArgs, pub common: CommonClientArgs,
/// Output format. /// Event row output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)] #[arg(
pub output: OutputMode, 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,
/// Include extra fields in human output. /// 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)] #[arg(long)]
pub verbose: bool, pub verbose: bool,
} }
impl ClientArgs { impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig { pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration) 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 { pub fn is_continuous(&self) -> bool {
@@ -46,6 +106,24 @@ impl ClientArgs {
pub fn timestamp_mode(&self) -> TimestampArg { pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp 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 { impl std::ops::Deref for ClientArgs {
@@ -57,23 +135,36 @@ impl std::ops::Deref for ClientArgs {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode { pub enum OutputFormat {
/// Readable terminal output with a final summary. /// Readable terminal table output.
Human, Table,
/// Parseable full event fields. /// Comma-separated event rows.
Machine, Csv,
/// Simple key=value-ish event stream. /// Tab-separated event rows.
Simple, Tsv,
/// RTT microseconds only. /// One JSON object per event row.
RttUs, Jsonl,
} }
impl OutputMode { pub type CliTargetSpec = TargetSpec;
pub type ResolvedCliTarget = ResolvedTarget;
impl OutputFormat {
pub fn prints_summary(self) -> bool { pub fn prints_summary(self) -> bool {
matches!(self, Self::Human) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -91,10 +182,14 @@ mod tests {
fn client_parser_keeps_finite_default_duration() { fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112"); 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.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1)); assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human); assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.is_continuous()); assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both); assert_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -107,17 +202,105 @@ mod tests {
} }
#[test] #[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() { fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] { for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok()); assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
} }
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err()); 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] #[test]
fn client_help_lists_shared_protocol_options() { fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string(); let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>")); 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("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>")); assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>")); assert!(help.contains("--sfill <STRING>"));
@@ -127,10 +310,10 @@ mod tests {
} }
#[test] #[test]
fn output_mode_summary_policy_is_human_only() { fn output_format_summary_policy_is_table_only() {
assert!(OutputMode::Human.prints_summary()); assert!(OutputFormat::Table.prints_summary());
assert!(!OutputMode::Simple.prints_summary()); assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputMode::Machine.prints_summary()); assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputMode::RttUs.prints_summary()); assert!(!OutputFormat::Jsonl.prints_summary());
} }
} }
+1 -5
View File
@@ -4,9 +4,5 @@ pub mod run;
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
pub mod summary; pub mod summary;
pub use args::{ClientArgs, OutputMode}; pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use run::run_stream; pub use run::run_stream;
File diff suppressed because it is too large. Load diff
+372 -91
View File
@@ -1,15 +1,23 @@
#[cfg(feature = "stats")]
use std::collections::BTreeMap;
use std::{ use std::{
collections::HashSet,
io::{self, Write}, io::{self, Write},
sync::atomic::AtomicBool, sync::atomic::AtomicBool,
thread,
time::{Duration, Instant},
}; };
use irtt_client::ClientEvent; use irtt_client::{
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow, TargetEvent,
};
#[cfg(all(test, feature = "stats"))] #[cfg(all(test, feature = "stats"))]
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration}; use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{ use super::{
args::{ClientArgs, OutputMode}, args::{ClientArgs, ResolvedCliTarget},
output::{format_event, format_human_event_with_options, HumanOutputOptions}, output::{EventRenderStats, OutputConfig},
}; };
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count; use crate::shared::client::expected_probe_count;
@@ -33,30 +41,56 @@ const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB; 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( pub fn run_stream(
args: ClientArgs, args: ClientArgs,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output; 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(); let continuous = args.is_continuous();
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) { if let Some(warning) = finite_stats_memory_warning(&args) {
eprintln!("{warning}"); 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()); let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous)); let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode, config: output_config,
human_options: HumanOutputOptions { header_printed: false,
verbose: args.verbose,
},
print_final_summary: false, print_final_summary: false,
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
}; };
if is_shutdown_requested(shutdown_requested) { if is_shutdown_requested(shutdown_requested) {
@@ -70,12 +104,18 @@ pub fn run_stream(
} }
let events = session.open()?; let events = session.open()?;
stream_output.print_events(&events)?; #[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; let mut interrupted = false;
while session.should_continue(shutdown_requested) { while session.should_continue(shutdown_requested) {
let events = session.step(shutdown_requested)?; let events = session.step(shutdown_requested)?;
stream_output.print_events(&events)?; #[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) { if is_shutdown_requested(shutdown_requested) {
interrupted = true; interrupted = true;
@@ -92,92 +132,329 @@ pub fn run_stream(
if session.should_drain_final(interrupted) { if session.should_drain_final(interrupted) {
session.drain_final(|events| { session.drain_final(|events| {
let _ = stream_output.print_events(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()?; let events = session.poll_timeouts()?;
stream_output.print_events(&events)?; #[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()?; let events = session.close()?;
stream_output.print_events(&events)?; #[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); let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary; stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && 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.print_summary()?;
stream_output.out.flush()?; stream_output.out.flush()?;
Ok(()) Ok(())
} }
struct StreamOutput<'a, W: Write> { struct StreamOutput<'a, W: Write> {
mode: OutputMode, config: OutputConfig,
human_options: HumanOutputOptions, header_printed: bool,
print_final_summary: bool, print_final_summary: bool,
show_running_only_summary_note: bool, show_running_only_summary_note: bool,
out: &'a mut W, out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
} }
impl<W: Write> StreamOutput<'_, W> { impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> { fn print_events(
for event in events { &mut self,
self.print_event(event)?; 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(()) Ok(())
} }
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> { fn print_event(
#[cfg(feature = "stats")] &mut self,
let stats_update = self.stats.process(event); event: &ClientEvent,
target: Option<&str>,
#[cfg(not(feature = "stats"))] stats_update: &EventRenderStats,
let stats_update = crate::cmd::client::HumanEventStats::default(); ) -> io::Result<()> {
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
#[cfg(feature = "stats")]
let human_stats = stats_update.into();
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
Some(format_human_event_with_options(
event,
Some(human_stats),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?; writeln!(self.out, "{line}")?;
} }
Ok(()) Ok(())
} }
fn print_summary(&mut self) -> io::Result<()> { fn print_header(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() { if self.header_printed {
return Ok(()); return Ok(());
} }
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
{ fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if !self.print_final_summary || !self.config.prints_summary() {
return Ok(());
}
write!( write!(
self.out, self.out,
"{}", "{}",
crate::cmd::client::summary::format_summary_with_options( crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(), &stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions { crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose, verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note, show_running_only_note: self.show_running_only_summary_note,
}, },
) )
)?; )?;
}
Ok(()) 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")] #[cfg(feature = "stats")]
@@ -278,6 +555,15 @@ mod tests {
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap() <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] #[test]
fn output_helper_streams_and_collects_events() { fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1)); let sent_at = test_timestamp(Duration::from_secs(1));
@@ -314,20 +600,24 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::RttUs, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Tsv,
Some("effective_rtt_us"),
crate::cmd::client::HeaderMode::Never,
false,
),
header_printed: false,
print_final_summary: true, print_final_summary: true,
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut out, out: &mut out,
stats: &mut stats,
}; };
stream_output.print_events(&events).unwrap(); print_events_with_stats(&mut stream_output, &events, None, &mut stats).unwrap();
stream_output.print_summary().unwrap(); stream_output.print_summary(&stats).unwrap();
} }
let rendered = String::from_utf8(out).unwrap(); let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot(); let summary = stats.snapshot();
assert_eq!(rendered, "1200\n"); assert!(!rendered.is_empty());
assert_eq!(summary.packets.packets_sent, 1); assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1); assert_eq!(summary.packets.unique_replies, 1);
} }
@@ -338,15 +628,25 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::Human, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: true, print_final_summary: true,
show_running_only_summary_note: true, show_running_only_summary_note: true,
out: &mut out, out: &mut out,
stats: &mut stats,
}; };
stream_output.print_event(&reply_event(1, 1200)).unwrap(); print_events_with_stats(
stream_output.print_summary().unwrap(); &mut stream_output,
&[reply_event(1, 1200)],
None,
&mut stats,
)
.unwrap();
stream_output.print_summary(&stats).unwrap();
} }
let rendered = String::from_utf8(out).unwrap(); let rendered = String::from_utf8(out).unwrap();
@@ -356,46 +656,27 @@ mod tests {
assert!(rendered.contains("packets:")); assert!(rendered.contains("packets:"));
assert!(rendered.contains("received=1")); assert!(rendered.contains("received=1"));
let mut stats = StatsCollector::new(StatsConfig::continuous()); let stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::Human, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: false, print_final_summary: false,
show_running_only_summary_note: true, show_running_only_summary_note: true,
out: &mut out, out: &mut out,
stats: &mut stats,
}; };
stream_output.print_summary().unwrap(); stream_output.print_summary(&stats).unwrap();
} }
assert!(out.is_empty()); assert!(out.is_empty());
} }
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test] #[test]
fn continuous_mode_uses_continuous_stats_config() { fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true)); let mut collector = StatsCollector::new(stats_config(true));
+1 -2
View File
@@ -3,5 +3,4 @@ pub mod client;
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
pub mod tui; pub mod tui;
// Future command applets, such as a server applet, belong here behind their // Additional command applets belong here behind their own feature gate.
// own feature gate.
+193 -24
View File
@@ -5,15 +5,42 @@ use irtt_client::ClientConfig;
#[cfg(test)] #[cfg(test)]
use crate::shared::client::TimestampArg; use crate::shared::client::TimestampArg;
use crate::shared::client::{parse_test_duration, CommonClientArgs}; 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; pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] #[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs { pub struct TuiArgs {
/// Server address or host, with optional port. /// Server address or host, with optional port. Repeat for multi-target mode.
pub server: String, #[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( #[arg(
long, long,
@@ -30,19 +57,44 @@ pub struct TuiArgs {
impl TuiArgs { impl TuiArgs {
pub fn to_client_config(&self) -> ClientConfig { pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration) self.common.to_client_config(
self.primary_target_addr()
.expect("at least one target is required"),
self.duration,
)
} }
pub fn is_continuous(&self) -> bool { pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO 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)] #[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg { pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp 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 { impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs; type Target = CommonClientArgs;
@@ -54,25 +106,32 @@ impl std::ops::Deref for TuiArgs {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cmd::client::ClientArgs;
use clap::{CommandFactory, Parser}; use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct SharedOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> { fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"]; let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args); argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv) TuiArgs::try_parse_from(argv)
} }
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> { fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
let mut argv = vec!["irtt-cli"]; let mut argv = vec!["shared-only"];
argv.extend_from_slice(args); argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv) SharedOnlyArgs::try_parse_from(argv)
} }
#[test] #[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() { fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112"); 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_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous()); assert!(args.is_continuous());
assert_eq!(args.to_client_config().duration, None); assert_eq!(args.to_client_config().duration, None);
@@ -90,8 +149,110 @@ mod tests {
} }
#[test] #[test]
fn shared_client_options_match_between_stream_and_tui_parsers() { 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 = [ 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", "--duration",
"30s", "30s",
"--interval", "--interval",
@@ -115,20 +276,28 @@ mod tests {
"--loose", "--loose",
"127.0.0.1:2112", "127.0.0.1:2112",
]; ];
let client = parse_client(&shared).unwrap().to_client_config(); let tui = parse(&tui_args).unwrap().to_client_config();
let tui = parse(&shared).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!(client.server_addr, tui.server_addr); assert_eq!(shared.server_addr, tui.server_addr);
assert_eq!(client.duration, tui.duration); assert_eq!(shared.duration, tui.duration);
assert_eq!(client.interval, tui.interval); assert_eq!(shared.interval, tui.interval);
assert_eq!(client.length, tui.length); assert_eq!(shared.length, tui.length);
assert_eq!(client.received_stats, tui.received_stats); assert_eq!(shared.received_stats, tui.received_stats);
assert_eq!(client.stamp_at, tui.stamp_at); assert_eq!(shared.stamp_at, tui.stamp_at);
assert_eq!(client.clock, tui.clock); assert_eq!(shared.clock, tui.clock);
assert_eq!(client.dscp, tui.dscp); assert_eq!(shared.dscp, tui.dscp);
assert_eq!(client.hmac_key, tui.hmac_key); assert_eq!(shared.hmac_key, tui.hmac_key);
assert_eq!(client.server_fill, tui.server_fill); assert_eq!(shared.server_fill, tui.server_fill);
assert_eq!(client.negotiation_policy, tui.negotiation_policy); assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl); 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>"));
} }
} }
+287 -35
View File
@@ -1,39 +1,41 @@
use std::{ use std::{
io, io,
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_stats::StatsCollector; use irtt_client::{
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow,
};
use crate::{ use crate::{
cmd::tui::args::TuiArgs, cmd::tui::args::{ResolvedTuiTarget, TuiArgs},
shared::client::{is_shutdown_requested, ClientSession}, shared::client::{is_shutdown_requested, ClientSession},
}; };
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal}; use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
if continuous {
irtt_stats::StatsConfig::continuous()
} else {
irtt_stats::StatsConfig::finite()
}
}
const RENDER_INTERVAL: Duration = Duration::from_millis(250); const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20); const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5); const IDLE_SLEEP: Duration = Duration::from_millis(5);
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
pub fn run_tui( pub fn run_tui(
args: TuiArgs, args: TuiArgs,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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 continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?; let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::new(TuiConfig::from_args(&args)); let mut state = TuiState::with_target_labels(
let mut stats = StatsCollector::new(stats_config(continuous)); TuiConfig::from_args(&args),
targets.iter().map(|target| target.label.clone()),
);
let mut next_render = Instant::now(); let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) { if is_shutdown_requested(shutdown_requested) {
@@ -41,13 +43,24 @@ pub fn run_tui(
} }
state.set_status(TuiStatus::Opening); state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; 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) { let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
Ok(session) => session, Ok(session) => session,
Err(err) => { Err(err) => {
state.set_error(err.to_string()); state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(err)); return Err(Box::new(err));
} }
}; };
@@ -57,8 +70,8 @@ pub fn run_tui(
} }
let events = session.open()?; let events = session.open()?;
state.process_events(&events, &mut stats); state.process_events(&events);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
let mut interrupted = false; let mut interrupted = false;
// Keep this in lockstep with run_stream: send due probes, drain available // Keep this in lockstep with run_stream: send due probes, drain available
@@ -66,7 +79,7 @@ pub fn run_tui(
// then perform the same final drain, timeout poll, and close sequence. // then perform the same final drain, timeout poll, and close sequence.
while session.should_continue(shutdown_requested) { while session.should_continue(shutdown_requested) {
if handle_input(&mut state, shutdown_requested)? { if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
} }
if state.quit_requested { if state.quit_requested {
interrupted = true; interrupted = true;
@@ -74,19 +87,18 @@ pub fn run_tui(
} }
let events = session.step(shutdown_requested)?; let events = session.step(shutdown_requested)?;
state.process_events(&events, &mut stats); state.process_events(&events);
if is_shutdown_requested(shutdown_requested) { if is_shutdown_requested(shutdown_requested) {
interrupted = true; interrupted = true;
break; break;
} }
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?; render_if_due(&mut terminal, &state, &mut next_render, false)?;
wait_for_tui_activity( wait_for_tui_activity(
session.next_send_deadline(), session.next_send_deadline(),
&mut next_render, &mut next_render,
&mut state, &mut state,
&stats,
&mut terminal, &mut terminal,
shutdown_requested, shutdown_requested,
)?; )?;
@@ -95,32 +107,189 @@ pub fn run_tui(
if interrupted { if interrupted {
state.set_status(TuiStatus::Interrupted); state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
} }
if session.should_drain_final(interrupted) { if session.should_drain_final(interrupted) {
state.set_status(TuiStatus::Draining); state.set_status(TuiStatus::Draining);
let mut drain_render = Instant::now(); let mut drain_render = Instant::now();
session.drain_final(|events| { session.drain_final(|events| {
state.process_events(events, &mut stats); state.process_events(events);
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false); let _ = render_if_due(&mut terminal, &state, &mut drain_render, false);
})?; })?;
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
} }
let events = session.poll_timeouts()?; let events = session.poll_timeouts()?;
state.process_events(&events, &mut stats); state.process_events(&events);
state.set_status(TuiStatus::Closing); state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
let events = session.close()?; let events = session.close()?;
state.process_events(&events, &mut stats); state.process_events(&events);
state.set_status(TuiStatus::Complete); state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(()) 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> { fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false; let mut force_render = false;
while event::poll(Duration::ZERO)? { while event::poll(Duration::ZERO)? {
@@ -150,11 +319,47 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
force_render = true; force_render = true;
} }
KeyCode::Char('g') => { KeyCode::Char('g') => {
state.cycle_graph_mode(); state.toggle_view();
force_render = true; force_render = true;
} }
KeyCode::Char('f') => { KeyCode::Char('m') => {
state.toggle_full_graph(); 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; force_render = true;
} }
_ => {} _ => {}
@@ -166,7 +371,6 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
fn render_if_due( fn render_if_due(
terminal: &mut TuiTerminal, terminal: &mut TuiTerminal,
state: &TuiState, state: &TuiState,
stats: &StatsCollector,
next_render: &mut Instant, next_render: &mut Instant,
force: bool, force: bool,
) -> io::Result<()> { ) -> io::Result<()> {
@@ -174,7 +378,7 @@ fn render_if_due(
if !should_render(now, *next_render, state.paused, force) { if !should_render(now, *next_render, state.paused, force) {
return Ok(()); return Ok(());
} }
terminal.draw(state, &stats.snapshot())?; terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL; *next_render = now + RENDER_INTERVAL;
Ok(()) Ok(())
} }
@@ -183,7 +387,6 @@ fn wait_for_tui_activity(
next_send_deadline: Option<Instant>, next_send_deadline: Option<Instant>,
next_render: &mut Instant, next_render: &mut Instant,
state: &mut TuiState, state: &mut TuiState,
stats: &StatsCollector,
terminal: &mut TuiTerminal, terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> io::Result<()> { ) -> io::Result<()> {
@@ -193,7 +396,7 @@ fn wait_for_tui_activity(
} }
if handle_input(state, shutdown_requested)? { if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, stats, next_render, true)?; render_if_due(terminal, state, next_render, true)?;
} }
Ok(()) Ok(())
} }
@@ -214,3 +417,52 @@ fn tui_wait_duration(
}; };
send_wait.min(render_wait).min(TUI_WAIT_SLICE) 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
@@ -5,14 +5,12 @@ use std::{
sync::{atomic::AtomicBool, Arc}, sync::{atomic::AtomicBool, Arc},
}; };
use irtt_cli::{ use crate::{
applet::{ applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
},
signal::install_signal_handler, signal::install_signal_handler,
}; };
fn main() -> ExitCode { pub fn main() -> ExitCode {
match run_from_env() { match run_from_env() {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(err) => { Err(err) => {
@@ -24,13 +22,12 @@ fn main() -> ExitCode {
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> { fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect(); let argv: Vec<OsString> = env::args_os().collect();
let argv0 = argv let (requested, argv) = match dispatch_from_argv(argv)? {
.first() AppletDispatch::Run { applet, argv } => (applet, argv),
.and_then(|arg| arg.to_str()) AppletDispatch::Help(help) => {
.unwrap_or("irtt-rs"); print!("{help}");
let requested = match detect_applet_from_argv0(argv0) { return Ok(());
Some(applet) => applet, }
None => default_requested_applet()?,
}; };
let shutdown_requested = Arc::new(AtomicBool::new(false)); let shutdown_requested = Arc::new(AtomicBool::new(false));
@@ -44,15 +41,6 @@ fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
fn default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
AppletAvailability::Client => Ok(RequestedApplet::Client),
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
AppletAvailability::Server => Ok(RequestedApplet::Server),
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
}
}
#[cfg(feature = "client")] #[cfg(feature = "client")]
fn run_client_applet( fn run_client_applet(
argv: Vec<OsString>, argv: Vec<OsString>,
@@ -60,8 +48,8 @@ fn run_client_applet(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser; use clap::Parser;
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv); let args = crate::cmd::client::ClientArgs::parse_from(argv);
irtt_cli::cmd::client::run_stream(args, shutdown_requested) crate::cmd::client::run_stream(args, shutdown_requested)
} }
#[cfg(not(feature = "client"))] #[cfg(not(feature = "client"))]
@@ -79,8 +67,8 @@ fn run_tui_applet(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser; use clap::Parser;
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv); let args = crate::cmd::tui::TuiArgs::parse_from(argv);
irtt_cli::cmd::tui::run_tui(args, shutdown_requested) crate::cmd::tui::run_tui(args, shutdown_requested)
} }
#[cfg(not(feature = "tui"))] #[cfg(not(feature = "tui"))]
+2
View File
@@ -2,5 +2,7 @@
pub mod applet; pub mod applet;
pub mod cmd; pub mod cmd;
#[doc(hidden)]
pub mod entry;
pub mod shared; pub mod shared;
pub mod signal; pub mod signal;
+5
View File
@@ -1,6 +1,7 @@
pub mod args; pub mod args;
pub mod config; pub mod config;
pub mod session; pub mod session;
pub mod targets;
pub use args::{ pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl, parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
@@ -8,3 +9,7 @@ pub use args::{
}; };
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT}; pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET}; 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,
};
@@ -40,12 +40,16 @@ impl ClientSession {
events.extend(self.client.send_probe()?); events.extend(self.client.send_probe()?);
} }
events.extend(self.client.recv_available(RECV_BUDGET)?); events.extend(self.client.recv_available(RECV_BUDGET)?);
if self.client.is_peer_closed() {
return Ok(events);
}
events.extend(self.client.poll_timeouts()?); events.extend(self.client.poll_timeouts()?);
Ok(events) Ok(events)
} }
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool { pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested) !is_shutdown_requested(shutdown_requested)
&& !self.client.is_peer_closed()
&& (self.continuous || self.client.next_send_deadline().is_some()) && (self.continuous || self.client.next_send_deadline().is_some())
} }
@@ -59,12 +63,20 @@ impl ClientSession {
{ {
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout()); let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
loop { loop {
if self.client.is_peer_closed() {
break;
}
let mut received = false; let mut received = false;
let events = self.client.recv_available(RECV_BUDGET)?; let events = self.client.recv_available(RECV_BUDGET)?;
received |= !events.is_empty(); received |= !events.is_empty();
on_events(&events); on_events(&events);
if self.client.is_peer_closed() {
break;
}
let events = self.client.poll_timeouts()?; let events = self.client.poll_timeouts()?;
received |= !events.is_empty(); received |= !events.is_empty();
on_events(&events); on_events(&events);
@@ -81,10 +93,16 @@ impl ClientSession {
} }
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> { 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() self.client.poll_timeouts()
} }
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> { pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
if self.client.is_peer_closed() {
return Ok(vec![]);
}
self.client.close() self.client.close()
} }
@@ -139,6 +157,29 @@ fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn final_drain_uses_capped_probe_timeout() { fn final_drain_uses_capped_probe_timeout() {
@@ -159,4 +200,171 @@ mod tests {
assert!(should_drain_final(true, true)); assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false)); 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"));
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
#[cfg(feature = "client")] #[cfg(feature = "client-runtime")]
pub mod client; pub mod client;
+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()
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.3.0" version = "0.4.0"
description = "IRTT-compatible Rust client library" description = "IRTT-compatible Rust client library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+85 -749
View File
@@ -1,36 +1,32 @@
use std::{ use std::{
io, io,
net::{SocketAddr, UdpSocket}, net::{SocketAddr, UdpSocket},
time::{Duration, Instant, SystemTime, UNIX_EPOCH}, time::{Duration, Instant},
}; };
use irtt_proto::{ #[cfg(test)]
close::CloseRequest, decode_echo_reply, echo_packet_len, encode_close_request, use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
encode_echo_request, encode_open_request, flags, EchoReply, EchoRequest, OpenReply,
OpenRequest, Params, ServerFill, TimestampFields, PROTOCOL_VERSION,
};
use crate::{ use crate::{
config::{ config::{ClientConfig, RecvBudget},
ClientConfig, RecvBudget, RunMode, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES,
MAX_UDP_PAYLOAD_LENGTH,
},
error::ClientError, error::ClientError,
event::{ event::{ClientEvent, OpenOutcome},
ClientEvent, OneWayDelaySample, OpenOutcome, ReceivedStatsSample, RttSample, ServerTiming,
SignedDuration, WarningKind,
},
metadata::ReceiveMeta,
probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
receive::recv_datagram, receive::recv_datagram,
session::{negotiate_params, ActiveSession, ClientPhase, NegotiatedParams}, runtime::{recv_buffer_size, SendProbeResult, SessionRuntime, MAX_OPEN_PACKET_SIZE},
socket::{connect_udp_socket, resolve_remote, validate_open_timeouts}, socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
socket_options::{apply_dscp_to_socket, clear_dscp_on_socket}, socket_options::{apply_dscp_to_socket, clear_dscp_on_socket},
timing::ClientTimestamp, timing::ClientTimestamp,
}; };
const MAX_OPEN_PACKET_SIZE: usize = 512; #[cfg(test)]
const MIN_RECV_BUFFER_SIZE: usize = 2048; use crate::{
runtime::{
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
unix_epoch_ns_i64, update_highest_received,
},
session::negotiate_params,
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
};
/// Low-level synchronous IRTT client. /// Low-level synchronous IRTT client.
/// ///
@@ -40,13 +36,9 @@ const MIN_RECV_BUFFER_SIZE: usize = 2048;
/// instead. /// instead.
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
config: ClientConfig, runtime: SessionRuntime,
socket: UdpSocket, socket: UdpSocket,
remote: SocketAddr, remote: SocketAddr,
requested: Params,
negotiated: Option<NegotiatedParams>,
phase: ClientPhase,
session: Option<ActiveSession>,
recv_buffer: Vec<u8>, recv_buffer: Vec<u8>,
} }
@@ -58,29 +50,15 @@ impl Client {
/// IRTT open exchange. /// IRTT open exchange.
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> { pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
validate_open_timeouts(&config.open_timeouts)?; validate_open_timeouts(&config.open_timeouts)?;
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 remote = resolve_remote(&config)?; let remote = resolve_remote(&config)?;
let runtime = SessionRuntime::new(config.clone(), remote)?;
let socket = connect_udp_socket(&config.socket_config, remote)?; let socket = connect_udp_socket(&config.socket_config, remote)?;
let requested = params_from_config(&config)?;
Ok(Self { Ok(Self {
config, runtime,
socket, socket,
remote, remote,
requested, recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
negotiated: None,
phase: ClientPhase::Connected,
session: None,
recv_buffer: vec![0_u8; MIN_RECV_BUFFER_SIZE],
}) })
} }
@@ -90,34 +68,37 @@ impl Client {
/// client into either an open probe session or completed no-test state. /// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. /// Open attempts use [`ClientConfig::open_timeouts`].
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> { pub fn open(&mut self) -> Result<OpenOutcome, 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 outcome = (|| -> Result<OpenOutcome, ClientError> { let outcome = (|| -> Result<OpenOutcome, ClientError> {
let request = OpenRequest { let packet = self.runtime.open_packet()?;
params: self.requested.clone(),
close: self.config.run_mode == RunMode::NoTest,
};
let packet = encode_open_request(&request, self.config.hmac_key.as_deref())?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE]; let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
for timeout in &self.config.open_timeouts { for timeout in &self.runtime.config().open_timeouts {
self.socket.set_read_timeout(Some(*timeout))?; self.socket.set_read_timeout(Some(*timeout))?;
self.socket.send(&packet)?; self.socket.send(&packet)?;
match self.socket.recv(&mut buf) { match self.socket.recv(&mut buf) {
Ok(size) => { Ok(size) => {
let reply = irtt_proto::decode_open_reply( let reply = self.runtime.decode_open_reply(&buf[..size])?;
&buf[..size], let remote = self.remote;
self.config.hmac_key.as_deref(), let has_hmac = self.runtime.has_hmac();
)?; let socket = &self.socket;
let recv_buffer = &mut self.recv_buffer;
return self.accept_open_reply(reply, ClientTimestamp::now()); return self.runtime.accept_open_reply(
reply,
ClientTimestamp::now(),
|negotiated| {
let size = recv_buffer_size(has_hmac, Some(negotiated))?;
let negotiated_dscp = u8::try_from(negotiated.params.dscp)
.map_err(|_| ClientError::InvalidConfig {
reason: "negotiated dscp must be in range 0..=63"
.to_owned(),
})?;
apply_dscp_to_socket(socket, remote, negotiated_dscp)?;
recv_buffer.resize(size, 0);
Ok(())
},
);
} }
Err(err) Err(err)
if matches!( if matches!(
@@ -133,7 +114,7 @@ impl Client {
let restore = self let restore = self
.socket .socket
.set_read_timeout(self.config.socket_config.recv_timeout); .set_read_timeout(self.runtime.config().socket_config.recv_timeout);
match (outcome, restore) { match (outcome, restore) {
(Ok(outcome), Ok(())) => Ok(outcome), (Ok(outcome), Ok(())) => Ok(outcome),
@@ -148,44 +129,24 @@ impl Client {
/// The close event means the client has sent its close packet and stopped /// The close event means the client has sent its close packet and stopped
/// tracking the session locally; it is not a server acknowledgement. /// tracking the session locally; it is not a server acknowledgement.
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let token = match self.phase { let socket = &self.socket;
ClientPhase::Open { token } => token, let remote = self.remote;
ClientPhase::Closed => return Err(ClientError::AlreadyClosed), self.runtime.close_with(|packet| {
ClientPhase::Connected | ClientPhase::NoTestCompleted => { clear_dscp_on_socket(socket, remote)?;
return Err(ClientError::NotOpen) socket.send(packet)?;
} Ok(())
}; })
clear_dscp_on_socket(&self.socket, self.remote)?;
let packet =
encode_close_request(&CloseRequest { token }, self.config.hmac_key.as_deref())?;
self.socket.send(&packet)?;
self.phase = ClientPhase::Closed;
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(),
}])
} }
/// Return the monotonic deadline for the next probe send, if another probe /// Return the monotonic deadline for the next probe send, if another probe
/// is scheduled. /// is scheduled.
pub fn next_send_deadline(&self) -> Option<Instant> { pub fn next_send_deadline(&self) -> Option<Instant> {
let session = self.session.as_ref()?; self.runtime.next_send_deadline()
if session.sending_done {
return None;
}
Some(session.next_send_at)
} }
/// Return the local timeout used to classify pending probes as lost. /// Return the local timeout used to classify pending probes as lost.
pub fn probe_timeout(&self) -> Duration { pub fn probe_timeout(&self) -> Duration {
self.config.probe_timeout self.runtime.probe_timeout()
} }
/// Send one echo probe if the negotiated run is still active. /// Send one echo probe if the negotiated run is still active.
@@ -202,126 +163,6 @@ impl Client {
/// Returns an empty event list when the socket read would block or times /// Returns an empty event list when the socket read would block or times
/// out. Malformed or unrelated datagrams are reported as warning events. /// out. Malformed or unrelated datagrams are reported as warning events.
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(None)
}
fn send_probe_inner(
&mut self,
override_ts: Option<ClientTimestamp>,
) -> Result<Vec<ClientEvent>, 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 = 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 sent_at = override_ts.unwrap_or_else(ClientTimestamp::now);
let send_call_start = Instant::now();
let bytes = self.socket.send(&packet)?;
let send_call = send_call_start.elapsed();
let timer_error = instant_abs_diff(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,
timeout_at: 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 =
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,
bytes,
send_call,
timer_error,
}])
}
fn recv_once_inner(
&mut self,
override_ts: Option<ClientTimestamp>,
) -> 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 datagram = match recv_datagram(&self.socket, &mut self.recv_buffer) { let datagram = match recv_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => datagram, Ok(datagram) => datagram,
Err(err) Err(err)
@@ -335,16 +176,11 @@ impl Client {
Err(err) => return Err(ClientError::Socket(err)), Err(err) => return Err(ClientError::Socket(err)),
}; };
let now = override_ts.unwrap_or(datagram.received_at); self.runtime.process_received_echo_packet(
let packet_len = datagram.len; &self.recv_buffer[..datagram.len],
let Some(reply) = self.decode_received_packet(&self.recv_buffer[..packet_len]) else { datagram.received_at,
return Ok(vec![ClientEvent::Warning { datagram.meta,
kind: WarningKind::MalformedOrUnrelatedPacket, )
message: "dropped malformed or unrelated packet".to_owned(),
at: now,
}]);
};
self.process_echo_reply(reply, packet_len, now, datagram.meta)
} }
/// Receive and classify datagrams until a receive produces no events or the /// Receive and classify datagrams until a receive produces no events or the
@@ -357,6 +193,9 @@ impl Client {
break; break;
} }
all_events.extend(events); all_events.extend(events);
if self.is_peer_closed() {
break;
}
} }
Ok(all_events) Ok(all_events)
} }
@@ -374,30 +213,7 @@ impl Client {
/// ///
/// `now` is monotonic time only; wall-clock time is not used for timeout expiry. /// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> { pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
match self.phase { self.runtime.poll_timeouts_at(now)
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)
} }
/// Return whether the current run has completed. /// Return whether the current run has completed.
@@ -406,294 +222,42 @@ impl Client {
/// pending probes have either replied or timed out. No-test and closed /// pending probes have either replied or timed out. No-test and closed
/// sessions are also considered complete. /// sessions are also considered complete.
pub fn is_run_complete(&self) -> bool { pub fn is_run_complete(&self) -> bool {
let Some(session) = self.session.as_ref() else { self.runtime.is_run_complete()
return matches!( }
self.phase,
ClientPhase::Closed | ClientPhase::NoTestCompleted /// Return whether the session was closed by a peer close-flagged reply.
); ///
}; /// Direct operations on a closed client still return
session.sending_done && session.pending.len() == 0 /// [`ClientError::AlreadyClosed`]. This method lets higher-level run loops
/// avoid treating a successfully observed peer close as a local cleanup
/// failure.
pub fn is_peer_closed(&self) -> bool {
self.runtime.is_peer_closed()
} }
pub(crate) fn has_timed_out_metadata(&self) -> bool { pub(crate) fn has_timed_out_metadata(&self) -> bool {
self.session self.runtime.has_timed_out_metadata()
.as_ref()
.is_some_and(|session| session.timed_out.len() > 0)
} }
pub(crate) fn packets_sent(&self) -> u64 { pub(crate) fn packets_sent(&self) -> u64 {
self.session self.runtime.packets_sent()
.as_ref()
.map_or(0, |session| session.packets_sent)
} }
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> { fn send_probe_inner(
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, &mut self,
reply: EchoReply, override_ts: Option<ClientTimestamp>,
packet_len: usize,
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> { ) -> Result<Vec<ClientEvent>, ClientError> {
let token = match self.phase { let socket = &self.socket;
ClientPhase::Open { token } => token, self.runtime.send_probe_with(override_ts, |packet| {
_ => unreachable!(), let sent_at = override_ts.unwrap_or_else(ClientTimestamp::now);
}; let send_call_start = Instant::now();
if reply.token != token { let bytes = socket.send(packet)?;
return Ok(vec![ClientEvent::Warning { let send_call = send_call_start.elapsed();
kind: WarningKind::WrongToken, Ok(SendProbeResult {
message: format!( sent_at,
"dropped reply with wrong token: expected {token:#x}, got {:#x}", bytes,
reply.token send_call,
),
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>,
) -> Result<(), ClientError> {
clear_dscp_on_socket(&self.socket, self.remote)?;
self.phase = ClientPhase::Closed;
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,
});
Ok(())
}
fn accept_open_reply(
&mut self,
reply: OpenReply,
now: ClientTimestamp,
) -> Result<OpenOutcome, 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),
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),
}
}
fn accept_normal_open(
&mut self,
reply: OpenReply,
now: ClientTimestamp,
) -> Result<OpenOutcome, ClientError> {
let token = reply.token;
let negotiated = negotiate_params(
&self.requested,
reply.params,
self.config.negotiation_policy,
)?;
let recv_buffer_size = recv_buffer_size(self.config.hmac_key.is_some(), Some(&negotiated))?;
let negotiated_dscp =
u8::try_from(negotiated.params.dscp).map_err(|_| ClientError::InvalidConfig {
reason: "negotiated dscp must be in range 0..=63".to_owned(),
})?;
apply_dscp_to_socket(&self.socket, self.remote, negotiated_dscp)?;
self.negotiated = Some(negotiated.clone());
self.recv_buffer.resize(recv_buffer_size, 0);
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,
}) })
} }
} }
@@ -705,233 +269,5 @@ impl Client {
} }
} }
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)
}
fn sequence_is_after(candidate: u32, current: u32) -> bool {
candidate != current && candidate.wrapping_sub(current) < (1 << 31)
}
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 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,
})
}
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),
})
}
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,
})
}
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 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 }),
})
}
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(),
})
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+24
View File
@@ -118,6 +118,30 @@ pub struct ClientConfig {
pub max_pending_probes: usize, pub max_pending_probes: usize,
} }
/// Authentication settings used by client sessions.
///
/// `ClientConfig` keeps its existing top-level HMAC field for source
/// compatibility. Managed multi-target callers can use this smaller type for
/// per-target auth overrides without carrying a full independent
/// [`ClientConfig`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientAuthConfig {
/// Optional HMAC key used to authenticate IRTT packets.
pub hmac_key: Option<Vec<u8>>,
}
impl From<Option<Vec<u8>>> for ClientAuthConfig {
fn from(hmac_key: Option<Vec<u8>>) -> Self {
Self { hmac_key }
}
}
impl From<ClientAuthConfig> for Option<Vec<u8>> {
fn from(auth: ClientAuthConfig) -> Self {
auth.hmac_key
}
}
impl Default for ClientConfig { impl Default for ClientConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
+8 -4
View File
@@ -59,6 +59,7 @@ mod managed;
mod metadata; mod metadata;
mod probe; mod probe;
mod receive; mod receive;
mod runtime;
mod session; mod session;
mod socket; mod socket;
mod socket_options; mod socket_options;
@@ -66,8 +67,8 @@ mod timing;
pub use client::Client; pub use client::Client;
pub use config::{ pub use config::{
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT, ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
}; };
pub use error::{ClientError, EventSubscriptionError}; pub use error::{ClientError, EventSubscriptionError};
pub use event::{ pub use event::{
@@ -75,8 +76,11 @@ pub use event::{
ServerTiming, SignedDuration, WarningKind, ServerTiming, SignedDuration, WarningKind,
}; };
pub use managed::{ pub use managed::{
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession, CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientGroup,
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow, ManagedClientGroupConfig, ManagedClientGroupSession, ManagedClientSession,
ManagedGroupEndReason, ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig,
ManagedTargetEndReason, ManagedTargetOutcome, SessionEndReason, SessionOutcome,
SubscriberConfig, SubscriberOverflow, TargetEvent, TargetEventSubscription, TargetId,
}; };
pub use session::{NegotiatedParams, NegotiationRestriction}; pub use session::{NegotiatedParams, NegotiationRestriction};
pub use timing::ClientTimestamp; pub use timing::ClientTimestamp;
File diff suppressed because it is too large. Load diff
+62 -52
View File
@@ -45,20 +45,20 @@ pub enum SubscriberOverflow {
Disconnect, Disconnect,
} }
/// Publish/subscribe fan-out for [`ClientEvent`] values. /// Publish/subscribe fan-out for managed event values.
/// ///
/// `EventHub` is used by managed sessions internally and is also exported for /// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own /// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber. /// event producer. Publishing clones each event once per subscriber.
#[derive(Debug, Clone)] #[derive(Debug)]
pub struct EventHub { pub struct EventHub<T = ClientEvent> {
inner: Arc<HubInner>, inner: Arc<HubInner<T>>,
} }
#[derive(Debug)] #[derive(Debug)]
struct HubInner { struct HubInner<T> {
next_id: AtomicU64, next_id: AtomicU64,
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner>>>, subscribers: Mutex<HashMap<u64, Arc<SubscriberInner<T>>>>,
} }
/// Handle for receiving managed client events. /// Handle for receiving managed client events.
@@ -68,26 +68,34 @@ struct HubInner {
/// when that queue is full. Dropping the handle unregisters the subscriber. /// when that queue is full. Dropping the handle unregisters the subscriber.
#[must_use = "dropping the subscription unregisters it"] #[must_use = "dropping the subscription unregisters it"]
#[derive(Debug)] #[derive(Debug)]
pub struct EventSubscription { pub struct EventSubscription<T = ClientEvent> {
id: u64, id: u64,
hub: Weak<HubInner>, hub: Weak<HubInner<T>>,
inner: Arc<SubscriberInner>, inner: Arc<SubscriberInner<T>>,
} }
#[derive(Debug)] #[derive(Debug)]
struct SubscriberInner { struct SubscriberInner<T> {
state: Mutex<SubscriberState>, state: Mutex<SubscriberState<T>>,
available: Condvar, available: Condvar,
config: SubscriberConfig, config: SubscriberConfig,
} }
#[derive(Debug)] #[derive(Debug)]
struct SubscriberState { struct SubscriberState<T> {
queue: VecDeque<ClientEvent>, queue: VecDeque<T>,
connected: bool, 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. /// Create an empty event hub with no subscribers.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -104,7 +112,7 @@ impl EventHub {
pub fn subscribe( pub fn subscribe(
&self, &self,
config: SubscriberConfig, config: SubscriberConfig,
) -> Result<EventSubscription, crate::ClientError> { ) -> Result<EventSubscription<T>, crate::ClientError> {
if config.capacity == 0 { if config.capacity == 0 {
return Err(crate::ClientError::InvalidConfig { return Err(crate::ClientError::InvalidConfig {
reason: "subscriber capacity must be greater than zero".to_owned(), reason: "subscriber capacity must be greater than zero".to_owned(),
@@ -133,13 +141,41 @@ impl EventHub {
}) })
} }
/// 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. /// Publish an event to all currently connected subscribers.
/// ///
/// Slow subscribers are handled according to their /// Slow subscribers are handled according to their
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a /// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
/// subscriber to consume queued events. /// subscriber to consume queued events.
pub fn publish(&self, event: ClientEvent) { pub fn publish(&self, event: T) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self let subscribers: Vec<(u64, Arc<SubscriberInner<T>>)> = self
.inner .inner
.subscribers .subscribers
.lock() .lock()
@@ -170,47 +206,21 @@ impl EventHub {
subscribers.remove(&id); subscribers.remove(&id);
} }
} }
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self
.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.drain()
.map(|(_, subscriber)| subscriber)
.collect();
for subscriber in subscribers {
subscriber.disconnect();
}
}
#[cfg(test)]
fn subscriber_count(&self) -> usize {
self.inner
.subscribers
.lock()
.expect("event hub mutex poisoned")
.len()
}
} }
impl Default for EventHub { impl<T> Default for EventHub<T> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
impl EventSubscription { impl<T> EventSubscription<T> {
/// Block until the next queued event is available. /// Block until the next queued event is available.
/// ///
/// If the subscription is disconnected, this returns /// If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events /// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained. /// 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"); let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
loop { loop {
if let Some(event) = state.queue.pop_front() { if let Some(event) = state.queue.pop_front() {
@@ -233,7 +243,7 @@ impl EventSubscription {
/// is queued. If the subscription is disconnected, this returns /// is queued. If the subscription is disconnected, this returns
/// [`EventSubscriptionError::Disconnected`] after any already queued events /// [`EventSubscriptionError::Disconnected`] after any already queued events
/// have been drained. /// 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"); let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
if let Some(event) = state.queue.pop_front() { if let Some(event) = state.queue.pop_front() {
return Ok(Some(event)); return Ok(Some(event));
@@ -245,7 +255,7 @@ impl EventSubscription {
} }
} }
impl Drop for EventSubscription { impl<T> Drop for EventSubscription<T> {
fn drop(&mut self) { fn drop(&mut self) {
self.inner.disconnect(); self.inner.disconnect();
if let Some(hub) = self.hub.upgrade() { if let Some(hub) = self.hub.upgrade() {
@@ -257,8 +267,8 @@ impl Drop for EventSubscription {
} }
} }
impl SubscriberInner { impl<T> SubscriberInner<T> {
fn publish(&self, event: ClientEvent) -> bool { fn publish(&self, event: T) -> bool {
let mut state = self.state.lock().expect("subscriber mutex poisoned"); let mut state = self.state.lock().expect("subscriber mutex poisoned");
if !state.connected { if !state.connected {
return false; return false;
@@ -411,7 +421,7 @@ mod tests {
#[test] #[test]
fn dropping_subscription_unregisters_from_hub() { fn dropping_subscription_unregisters_from_hub() {
let hub = EventHub::new(); let hub: EventHub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap(); let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
assert_eq!(hub.subscriber_count(), 1); assert_eq!(hub.subscriber_count(), 1);
@@ -422,7 +432,7 @@ mod tests {
#[test] #[test]
fn disconnect_all_wakes_blocking_receivers() { fn disconnect_all_wakes_blocking_receivers() {
let hub = EventHub::new(); let hub: EventHub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap(); let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.disconnect_all(); hub.disconnect_all();
+6
View File
@@ -1,7 +1,13 @@
mod cancellation; mod cancellation;
mod group;
mod hub; mod hub;
mod runner; mod runner;
pub use cancellation::CancellationToken; 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 hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome}; pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
+98 -1
View File
@@ -224,7 +224,9 @@ fn run_client(
&mut counters, &mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?, client.recv_available(MANAGED_RECV_BUDGET)?,
); );
if !client.is_peer_closed() {
publish_events(&hub, &mut counters, client.poll_timeouts()?); publish_events(&hub, &mut counters, client.poll_timeouts()?);
}
break; break;
} }
@@ -243,6 +245,9 @@ fn run_client(
&mut counters, &mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?, client.recv_available(MANAGED_RECV_BUDGET)?,
); );
if client.is_peer_closed() {
break;
}
publish_events(&hub, &mut counters, client.poll_timeouts()?); publish_events(&hub, &mut counters, client.poll_timeouts()?);
if client.is_run_complete() { if client.is_run_complete() {
@@ -257,8 +262,10 @@ fn run_client(
} }
let packets_sent = client.packets_sent(); let packets_sent = client.packets_sent();
if !client.is_peer_closed() {
let close_events = client.close()?; let close_events = client.close()?;
publish_events(&hub, &mut counters, close_events); publish_events(&hub, &mut counters, close_events);
}
Ok(SessionOutcome { Ok(SessionOutcome {
end_reason: if cancelled { end_reason: if cancelled {
@@ -295,12 +302,20 @@ fn drain_final_late_replies(
let deadline = Instant::now() + MANAGED_FINAL_DRAIN; let deadline = Instant::now() + MANAGED_FINAL_DRAIN;
while Instant::now() < deadline && client.has_timed_out_metadata() { while Instant::now() < deadline && client.has_timed_out_metadata() {
if client.is_peer_closed() {
break;
}
let mut published = false; let mut published = false;
let events = client.recv_available(MANAGED_RECV_BUDGET)?; let events = client.recv_available(MANAGED_RECV_BUDGET)?;
published |= !events.is_empty(); published |= !events.is_empty();
publish_events(hub, counters, events); publish_events(hub, counters, events);
if client.is_peer_closed() {
break;
}
let events = client.poll_timeouts()?; let events = client.poll_timeouts()?;
published |= !events.is_empty(); published |= !events.is_empty();
publish_events(hub, counters, events); publish_events(hub, counters, events);
@@ -481,6 +496,53 @@ mod tests {
FakeServer { addr, done } 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) { fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
let mut buf = [0_u8; 2048]; let mut buf = [0_u8; 2048];
let (size, peer) = socket.recv_from(&mut buf).unwrap(); let (size, peer) = socket.recv_from(&mut buf).unwrap();
@@ -509,13 +571,23 @@ mod tests {
seq: u32, seq: u32,
params: &Params, params: &Params,
timestamps: &TimestampFields, 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> { ) -> Vec<u8> {
let layout = PacketLayout::echo(false, params); let layout = PacketLayout::echo(false, params);
let packet_len = test_echo_packet_len(false, params); let packet_len = test_echo_packet_len(false, params);
let mut packet = Vec::with_capacity(packet_len); let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC); packet.extend_from_slice(&MAGIC);
packet.push(FLAG_REPLY); packet.push(flags);
packet.extend_from_slice(&token.to_le_bytes()); packet.extend_from_slice(&token.to_le_bytes());
packet.extend_from_slice(&seq.to_le_bytes()); packet.extend_from_slice(&seq.to_le_bytes());
@@ -667,6 +739,31 @@ mod tests {
assert!(late_before_close < close); 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] #[test]
fn continuous_managed_run_can_be_stopped_cleanly() { fn continuous_managed_run_can_be_stopped_cleanly() {
let server = start_echo_server(test_params(None, Duration::from_millis(10))); let server = start_echo_server(test_params(None, Duration::from_millis(10)));
+32
View File
@@ -15,6 +15,14 @@ pub(crate) struct ReceivedDatagram {
pub(crate) meta: ReceiveMeta, 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")))] #[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn recv_datagram( pub(crate) fn recv_datagram(
socket: &UdpSocket, 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"))] #[cfg(all(target_os = "linux", feature = "ancillary"))]
pub(crate) fn recv_datagram( pub(crate) fn recv_datagram(
socket: &UdpSocket, socket: &UdpSocket,
@@ -38,6 +62,14 @@ pub(crate) fn recv_datagram(
linux::recv_datagram(socket, buf) 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")))] #[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn configure_receive_metadata( pub(crate) fn configure_receive_metadata(
_socket: &UdpSocket, _socket: &UdpSocket,
+107 -2
View File
@@ -1,13 +1,17 @@
#![allow(unsafe_code)] #![allow(unsafe_code)]
use std::{ use std::{
io, mem, io, mem,
net::{SocketAddr, UdpSocket}, net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6, UdpSocket},
os::fd::AsRawFd, os::fd::AsRawFd,
ptr, ptr,
time::{Duration, SystemTime, UNIX_EPOCH}, 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; 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 { unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
let mut meta = ReceiveMeta::default(); let mut meta = ReceiveMeta::default();
let mut cmsg = libc::CMSG_FIRSTHDR(msg); let mut cmsg = libc::CMSG_FIRSTHDR(msg);
@@ -108,6 +167,52 @@ unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
meta 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 { 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_IP && cmsg.cmsg_type == libc::IP_TOS)
|| (cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_TCLASS) || (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(),
})
}
+7 -1
View File
@@ -161,7 +161,13 @@ pub(crate) enum ClientPhase {
Connected, Connected,
Open { token: u64 }, Open { token: u64 },
NoTestCompleted, NoTestCompleted,
Closed, Closed { source: CloseSource },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CloseSource {
Local,
Peer,
} }
#[derive(Debug)] #[derive(Debug)]
+38
View File
@@ -99,6 +99,44 @@ pub(crate) fn connect_udp_socket(
Ok(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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-proto" name = "irtt-proto"
version = "0.3.0" version = "0.4.0"
description = "IRTT protocol encoding and decoding primitives" description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-stats" name = "irtt-stats"
version = "0.3.0" version = "0.4.0"
description = "Statistics aggregation for irtt-client events" description = "Statistics aggregation for irtt-client events"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+1
View File
@@ -44,6 +44,7 @@ allow = [
"Apache-2.0", "Apache-2.0",
"Unicode-3.0", "Unicode-3.0",
"BSD-3-Clause", "BSD-3-Clause",
"Zlib"
] ]
exceptions = [ exceptions = [
+2 -1
View File
@@ -11,4 +11,5 @@ ci = "github"
installers = [] installers = []
# Target platforms to build apps for (Rust target-triple syntax) # Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
features = ["tui"] # Features to pass to cargo build
features = ["full"]
+12 -18
View File
@@ -9,6 +9,7 @@ the local Rust client output.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import csv
import datetime as dt import datetime as dt
import json import json
import math import math
@@ -147,7 +148,7 @@ def parse_args() -> argparse.Namespace:
default=DEFAULT_IRTT_RS_COMMAND, default=DEFAULT_IRTT_RS_COMMAND,
help=( help=(
"Command prefix or direct path for the Rust client. Arguments for " "Command prefix or direct path for the Rust client. Arguments for "
"target, duration, interval, and machine output are appended. " "target, duration, interval, and CSV event output are appended. "
f"Default: {DEFAULT_IRTT_RS_COMMAND!r}" f"Default: {DEFAULT_IRTT_RS_COMMAND!r}"
), ),
) )
@@ -386,8 +387,10 @@ def run_case(
case.duration, case.duration,
"--interval", "--interval",
case.interval, case.interval,
"--output", "--format",
"machine", "csv",
"--columns",
"event,seq,effective_rtt_us,raw_rtt_us,adjusted_rtt_us,server_processing_us,client_send_wall_ns",
*args.rust_extra_arg, *args.rust_extra_arg,
] ]
@@ -402,7 +405,7 @@ def run_case(
rust_samples = case_dir / "irtt-rs-rtt-samples.csv" rust_samples = case_dir / "irtt-rs-rtt-samples.csv"
upstream_samples = case_dir / "upstream-rtt-samples.csv" upstream_samples = case_dir / "upstream-rtt-samples.csv"
rust_metrics = parse_rust_machine(case_dir / "irtt-rs.stdout", rust_samples) rust_metrics = parse_rust_csv(case_dir / "irtt-rs.stdout", rust_samples)
upstream_metrics = parse_upstream(case_dir / "upstream.stdout", upstream_json, upstream_samples) upstream_metrics = parse_upstream(case_dir / "upstream.stdout", upstream_json, upstream_samples)
comparison = classify_difference(upstream_metrics, rust_metrics) comparison = classify_difference(upstream_metrics, rust_metrics)
@@ -529,16 +532,7 @@ def wait_process(proc: subprocess.Popen[bytes] | None) -> int:
return proc.wait() return proc.wait()
def parse_kv_line(line: str) -> dict[str, str]: def parse_rust_csv(path: Path, samples_path: Path) -> Metrics:
fields: dict[str, str] = {}
for token in shlex.split(line):
if "=" in token:
key, value = token.split("=", 1)
fields[key] = value
return fields
def parse_rust_machine(path: Path, samples_path: Path) -> Metrics:
metrics = Metrics(source="irtt-rs", samples_path=str(samples_path)) metrics = Metrics(source="irtt-rs", samples_path=str(samples_path))
effective: list[float] = [] effective: list[float] = []
raw: list[float] = [] raw: list[float] = []
@@ -552,11 +546,11 @@ def parse_rust_machine(path: Path, samples_path: Path) -> Metrics:
metrics.parse_notes.append(f"missing {path}") metrics.parse_notes.append(f"missing {path}")
return metrics return metrics
for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): text = path.read_text(encoding="utf-8", errors="replace")
if not line.strip(): for fields in csv.DictReader(line for line in text.splitlines() if line.strip()):
continue
fields = parse_kv_line(line)
event = fields.get("event") event = fields.get("event")
if event is None:
continue
seq = parse_int(fields.get("seq")) seq = parse_int(fields.get("seq"))
if event == "echo_reply": if event == "echo_reply":
if seq is not None: if seq is not None: