Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f39ebe8aa | ||
|
|
68c7f92cb4 | ||
|
|
d2ae68a05f | ||
|
|
f9c6728855 | ||
|
|
b9f5367ed1 | ||
|
|
c483a12173 | ||
|
|
c5ca3d5a93 | ||
|
|
baa2a862f5 | ||
|
|
e987d31f2f | ||
|
|
6602b7496a | ||
|
|
a62c53a77e | ||
|
|
836a8abbeb | ||
|
|
2065fe5e11 | ||
|
|
69fd24ce64 | ||
|
|
a8622ee15a | ||
|
|
5dce8d55ee | ||
|
|
543f0e57c8 | ||
|
|
acffc3cd4e | ||
|
|
ac82537043 | ||
|
|
170f229e7b | ||
|
|
5b40593fda | ||
|
|
b33f01cfe1 | ||
|
|
f1d442d798 | ||
|
|
d05d6a941b | ||
|
|
9c63a03454 | ||
|
|
b02bc53cd8 | ||
|
|
4b408d3ab4 | ||
|
|
f9f7d35793 | ||
|
|
62e0f0cf9f | ||
|
|
193487ae64 | ||
|
|
cfbac69ac6 | ||
|
|
f72f8fda5a | ||
|
|
c7d03c0358 | ||
|
|
97dc1b3f8d | ||
|
|
e33ae1d0fc | ||
|
|
d4976fb811 | ||
|
|
74ceef6a70 | ||
|
|
fe776e6672 | ||
|
|
efaa60cbd4 | ||
|
|
dc65daec75 | ||
|
|
e3b74441a7 | ||
|
|
5595d6e78e | ||
|
|
fe1f1a9c5b | ||
|
|
afe07f0b9a |
No files matched your search
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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(());
|
||||||
|
}
|
||||||
|
self.header_printed = true;
|
||||||
|
if let Some(header) = self.config.render_header() {
|
||||||
|
writeln!(self.out, "{header}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
||||||
|
if !self.print_final_summary || !self.config.prints_summary() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
write!(
|
||||||
{
|
self.out,
|
||||||
write!(
|
"{}",
|
||||||
self.out,
|
crate::cmd::client::summary::format_summary_with_options(
|
||||||
"{}",
|
&stats.snapshot(),
|
||||||
crate::cmd::client::summary::format_summary_with_options(
|
crate::cmd::client::summary::SummaryFormatOptions {
|
||||||
&self.stats.snapshot(),
|
verbose: self.config.summary_verbose(),
|
||||||
crate::cmd::client::summary::SummaryFormatOptions {
|
show_running_only_note: self.show_running_only_summary_note,
|
||||||
verbose: self.human_options.verbose,
|
},
|
||||||
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));
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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>"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1557
-435
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,5 +2,7 @@
|
|||||||
|
|
||||||
pub mod applet;
|
pub mod applet;
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod entry;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
@@ -1,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, ¶ms), 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,
|
||||||
|
¶ms,
|
||||||
|
&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(¶ms.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(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_mono {
|
||||||
|
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_wall {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_mono {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_wall {
|
||||||
|
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_mono {
|
||||||
|
packet.extend_from_slice(×tamps.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,2 +1,2 @@
|
|||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client-runtime")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
@@ -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, ¶ms), 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,
|
||||||
|
¶ms,
|
||||||
|
&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(¶ms.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(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_mono {
|
||||||
|
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_wall {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_mono {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_wall {
|
||||||
|
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_mono {
|
||||||
|
packet.extend_from_slice(×tamps.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,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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -224,7 +224,9 @@ fn run_client(
|
|||||||
&mut counters,
|
&mut counters,
|
||||||
client.recv_available(MANAGED_RECV_BUDGET)?,
|
client.recv_available(MANAGED_RECV_BUDGET)?,
|
||||||
);
|
);
|
||||||
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
if !client.is_peer_closed() {
|
||||||
|
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();
|
||||||
let close_events = client.close()?;
|
if !client.is_peer_closed() {
|
||||||
publish_events(&hub, &mut counters, close_events);
|
let close_events = client.close()?;
|
||||||
|
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, ¶ms), 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,
|
||||||
|
¶ms,
|
||||||
|
&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)));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user