Compare commits

...
54 Commits
Author SHA1 Message Date
Nils Andreas Svee 352cebb5c3 build: enable tui feature in distribution workspace
Include the TUI module in release builds to ensure command-line interface functionality is available in distributed binaries.
2026-06-10 00:59:40 +07:00
Nils Andreas Svee 58fd017d4f build(ci): update cargo-dist to v0.32.0 and revert runner to ubuntu-22.04
Upgrade CI tooling to use cargo-dist v0.32.0 and switch to the ubuntu-22.04 runner to ensure environment compatibility. Pin GitHub Actions to major versions for better maintainability.
2026-06-10 00:47:34 +07:00
Nils Andreas Svee 5efb8a1870 build: bump workspace and crate versions to 0.3.0 2026-06-10 00:44:10 +07:00
Nils Andreas Svee f41fd4a8e7 refactor: simplify lib.rs and update feature gating for cmd modules 2026-06-10 00:41:03 +07:00
Nils Andreas Svee 5de1bf7343 docs: update README with multi-applet usage and installation instructions 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 7e89d11e6e build: configure multi-binary targets and feature-gate dependencies 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 196fc29f76 feat(tui): optimize terminal UI and simplify graph data series 2026-06-09 23:23:51 +07:00
Nils Andreas Svee ded8a5f87d refactor(client): encapsulate session lifecycle and stream output 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 133ff71b72 feat(cli): implement multi-applet binary dispatch and signal handling 2026-06-09 23:23:50 +07:00
Nils Andreas Svee d7cdcc5cee refactor(cli): reorganize project structure into modular commands 2026-06-09 23:23:41 +07:00
Nils Andreas Svee c31ad07d61 feat(irtt-cli): add optional tui output mode 2026-06-09 13:29:00 +07:00
renovate[bot] ece9fd6db0 chore(deps): maintain cargo lockfile 2026-06-09 11:32:29 +07:00
Nils Andreas Svee 9b0cc2b395 test(ci): split netem interop summaries into local and GitHub-specific reports
Create separate markdown files for local browsing with relative links and GitHub Action summaries with artifact notices. This ensures the GitHub Step Summary remains readable and informative without broken links.
2026-06-05 17:13:51 +07:00
renovate[bot] b544df7cc9 fix(deps): update socket dependencies to 0.6.4 2026-06-05 10:43:53 +07:00
renovate[bot] 5646365677 chore(deps): update github actions 2026-06-05 10:43:31 +07:00
Nils Andreas Svee 64be840c1e test(ci): enhance netem interop reporting and loss validation 2026-06-04 09:16:32 +07:00
Nils Andreas Svee b8b0425c4e test(ci): add deterministic Linux netem interop testing 2026-06-03 14:48:51 +07:00
Nils Andreas Svee 3e07732934 test(scripts): add interoperability harness to compare irtt clients
Add a Python-based black-box testing script to validate the Rust client's output and metrics against the upstream Go implementation.
2026-05-25 16:22:46 +07:00
renovate[bot] 61a242c5d4 chore(deps): update github actions 2026-05-25 15:02:49 +07:00
renovate[bot] 6bb4e70df3 chore(deps): maintain cargo lockfile 2026-05-25 01:56:28 +00:00
Nils Andreas Svee c739764476 ci: split actionlint into separate steps for handwritten and generated workflows
Disable ShellCheck globally for the cargo-dist generated release workflow to replace brittle regex ignore patterns with a cleaner configuration.
2026-05-18 13:50:18 +07:00
Nils Andreas Svee 5c96024c4f ci: simplify actionlint ignore patterns for generated release workflow
Update the regex patterns to be more robust and improve the clarity of comments regarding cargo-dist generated workflows.
2026-05-18 13:46:26 +07:00
Nils Andreas Svee 5d653dac13 fix(client): remove duplicate constant exports and definitions 2026-05-18 11:12:21 +07:00
Nils Andreas Svee 6f77d38a7f docs: document client errors and bounds 2026-05-18 10:56:58 +07:00
Nils Andreas Svee 01c10e2e0d docs: document client public API 2026-05-18 01:34:04 +07:00
Nils Andreas Svee 8fd0b803e9 docs: add public crate landing docs 2026-05-18 01:22:08 +07:00
Nils Andreas Svee 614ad5b5cd ci: ignore specific shellcheck warnings in generated release workflow
Suppress SC2086 and SC2129 linting errors for the generated release.yml to prevent build noise while maintaining checks for logic issues.
2026-05-18 01:05:40 +07:00
Nils Andreas Svee 2fcef0adc5 docs: clarify params validation boundaries 2026-05-18 00:36:18 +07:00
Nils Andreas Svee c7a0e5d055 chore: tighten release readiness checks 2026-05-18 00:30:07 +07:00
Nils Andreas Svee e117813578 test: consolidate and prune redundant unit tests
Simplify the test suite by merging similar test cases and removing redundant assertions that verify trivial initialization and basic container logic.
2026-05-18 00:16:51 +07:00
Nils Andreas Svee 3b5336a487 refactor(client): use SignedDuration for one-way delay to support negative values
Allow one-way delay samples to represent negative values caused by wall-clock skew between client and server. Update CLI output and statistics collection to correctly handle and report these signed durations.
2026-05-17 23:55:01 +07:00
Nils Andreas Svee 0b16ee802d fix(stats): support sequence number wraparound in IPDV tracking
Use wrapping arithmetic instead of checked arithmetic to ensure inter-packet delay variation (IPDV) is correctly calculated when sequence numbers wrap from u32::MAX to 0.
2026-05-17 23:11:26 +07:00
Nils Andreas Svee 64da60d480 feat(proto): enforce maximum length for server fill parameter
Move MAX_SERVER_FILL_BYTES to irtt-proto and validate it during decoding to prevent processing oversized parameters.
2026-05-17 22:59:57 +07:00
Nils Andreas Svee 2d6581f31f refactor(client): encapsulate SignedDuration and enhance its API
Hide internal fields to improve type safety and provide helper methods for unit conversions and negative value checks. Fix several feature-gated test compilation issues.
2026-05-17 22:47:16 +07:00
Nils Andreas Svee bec56d4114 refactor(client): use SignedDuration for effective RTT to support negative values
Simplify RttSample by merging signed and unsigned fields into a single SignedDuration for adjusted and effective RTT. This ensures accurate reporting and statistics when server processing time exceeds raw RTT.
2026-05-17 21:30:51 +07:00
Nils Andreas Svee 37de7c96a1 test(client): remove redundant probe/reply tests 2026-05-17 15:22:08 +07:00
Nils Andreas Svee 6c5972aa58 test(client): refactor probes and replies tests into submodules
Split the monolithic `probes_replies.rs` file into logical submodules and expose internal packet processing methods to improve test maintainability and coverage of boundary conditions.
2026-05-17 14:54:59 +07:00
renovate[bot] 55034fc23c chore(deps): update github actions 2026-05-17 14:49:03 +07:00
Nils Andreas Svee bed877760e test: prune client tests that assert private implementation details 2026-05-17 14:48:45 +07:00
renovate[bot] 87e1755a4b chore(deps): pin dependencies 2026-05-16 20:39:57 +00:00
Nils Andreas Svee 39d414d588 test: consolidate formatter and hub policy cases 2026-05-17 00:39:23 +07:00
Nils Andreas Svee 00fd0ebb15 test: consolidate duplicate CLI and client config cases 2026-05-17 00:34:18 +07:00
Nils Andreas Svee ddd420cc21 test: consolidate and prune implementation-coupled tests 2026-05-17 00:28:05 +07:00
Nils Andreas Svee a7288cc612 test(client): extract backend smoke tests into a dedicated module 2026-05-17 00:17:10 +07:00
Nils Andreas Svee fdcdb3b9d7 test(client): remove implementation-coupled client tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee bd9ed7eb88 test(client): trim implementation-coupled probe tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee 5d471b77fb refactor(irtt-cli): split stream runtime path 2026-05-13 22:20:51 +07:00
Nils Andreas Svee 61c527eb26 refactor(irtt-cli): rename stream output helper 2026-05-13 22:12:03 +07:00
Nils Andreas Svee f5c35e8142 feat(irtt-cli): note running-only continuous summaries 2026-05-13 22:05:18 +07:00
Nils Andreas Svee 9adab6882d test(cli): add tests for interrupted continuous mode summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee 67ef912ba0 feat(cli): print summary when continuous mode is interrupted 2026-05-13 22:01:56 +07:00
Nils Andreas Svee f2f5728c72 refactor(cli): rename print_finite_summary to print_final_summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee c19dac5aef docs(irtt-cli): expand duration flag documentation
Add detailed help text to clarify the difference in statistics handling between finite and continuous test modes.
2026-05-13 21:36:30 +07:00
Nils Andreas Svee 480cb4fa92 warn(irtt-cli): flag large finite stats runs 2026-05-13 20:18:45 +07:00
80 changed files with 8939 additions and 4965 deletions

No files matched your search

+19 -5
View File
@@ -23,13 +23,27 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
- name: Run actionlint - name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
zizmor: zizmor:
name: zizmor name: zizmor
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -42,11 +56,11 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with: with:
persona: regular persona: regular
+43 -4
View File
@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
@@ -44,7 +44,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
@@ -63,6 +63,38 @@ jobs:
--locked \ --locked \
-- -D warnings -- -D warnings
- name: Run clippy without default features
run: |
cargo clippy \
--workspace \
--all-targets \
--no-default-features \
--locked \
-- -D warnings
check:
name: cargo check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
cache-key: check
- name: Run check without default features
run: |
cargo check \
--workspace \
--no-default-features \
--locked
test: test:
name: cargo test name: cargo test
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -70,7 +102,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
@@ -87,6 +119,13 @@ jobs:
--all-features \ --all-features \
--locked --locked
- name: Run tests without default features
run: |
cargo test \
--workspace \
--no-default-features \
--locked
docs: docs:
name: cargo doc name: cargo doc
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -97,7 +136,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
+75
View File
@@ -0,0 +1,75 @@
name: Netem Interop
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
GOBIN: ${{ github.workspace }}/.tools/bin
IRTT_UPSTREAM_VERSION: v0.9.1
jobs:
netem:
name: netem interop
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
cache-key: interop-netem
- name: Check host tools
run: |
ip -V
tc -V
go version
- name: Install upstream irtt
run: |
mkdir -p "$GOBIN"
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
"$GOBIN/irtt" version
- name: Build irtt-rs CLI
run: cargo build -p irtt-cli --locked
- name: Run netem interop comparisons
run: |
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$GOBIN/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
- name: Append netem interop summary
if: always()
run: |
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
if [ -n "$latest_summary" ]; then
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
else
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload netem interop artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: interop-netem
path: target/interop-netem
+12 -12
View File
@@ -64,9 +64,9 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command # we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0 # failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
- name: Cache dist - name: Cache dist
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/dist path: ~/.cargo/bin/dist
@@ -82,7 +82,7 @@ jobs:
cat plan-dist-manifest.json cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json" - name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: artifacts-plan-dist-manifest name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json path: plan-dist-manifest.json
@@ -131,7 +131,7 @@ jobs:
run: ${{ matrix.install_dist.run }} run: ${{ matrix.install_dist.run }}
# Get the dist-manifest # Get the dist-manifest
- name: Fetch local artifacts - name: Fetch local artifacts
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -158,7 +158,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME" cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts" - name: "Upload artifacts"
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }} name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: | path: |
@@ -180,14 +180,14 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: recursive submodules: recursive
- name: Install cached dist - name: Install cached dist
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/ path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist - run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums) # Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts - name: Fetch local artifacts
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -205,7 +205,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME" cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts" - name: "Upload artifacts"
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: artifacts-build-global name: artifacts-build-global
path: | path: |
@@ -230,14 +230,14 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: recursive submodules: recursive
- name: Install cached dist - name: Install cached dist
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/ path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist - run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage # Fetch artifacts from scratch-storage
- name: Fetch artifacts - name: Fetch artifacts
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -250,14 +250,14 @@ jobs:
cat dist-manifest.json cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json" - name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
# Overwrite the previous copy # Overwrite the previous copy
name: artifacts-dist-manifest name: artifacts-dist-manifest
path: dist-manifest.json path: dist-manifest.json
# Create a GitHub Release while uploading all files to it # Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts" - name: "Download GitHub Artifacts"
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
pattern: artifacts-* pattern: artifacts-*
path: artifacts path: artifacts
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
+20 -5
View File
@@ -38,12 +38,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
- name: Run cargo-deny - name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2 uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
with: with:
command: check command: check
arguments: --all-features arguments: --all-features
@@ -56,9 +56,24 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
persist-credentials: false persist-credentials: false
- name: Run actionlint - name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
Generated
+525 -20
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@@ -38,7 +44,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -49,14 +55,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -76,6 +82,21 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -130,9 +151,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.3" version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
@@ -140,6 +161,20 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -147,10 +182,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "crypto-common" name = "crossterm"
version = "0.2.1" version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -163,7 +223,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [ dependencies = [
"dispatch2", "dispatch2",
"nix", "nix",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -175,6 +235,40 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.11.3" version = "0.11.3"
@@ -199,6 +293,45 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -223,20 +356,50 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "irtt-cli" name = "irtt-cli"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"clap", "clap",
"crossterm",
"ctrlc", "ctrlc",
"irtt-client", "irtt-client",
"irtt-proto", "irtt-proto",
"irtt-stats", "irtt-stats",
"ratatui",
] ]
[[package]] [[package]]
name = "irtt-client" name = "irtt-client"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"irtt-proto", "irtt-proto",
"libc", "libc",
@@ -246,7 +409,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
@@ -256,7 +419,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-stats" name = "irtt-stats"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"irtt-client", "irtt-client",
] ]
@@ -267,12 +430,57 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.11.0" version = "0.11.0"
@@ -283,6 +491,18 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.31.3" version = "0.31.3"
@@ -316,6 +536,35 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -335,21 +584,147 @@ dependencies = [
] ]
[[package]] [[package]]
name = "socket2" name = "ratatui"
version = "0.6.3" version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "signal-hook-registry",
] ]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -389,9 +764,9 @@ dependencies = [
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
@@ -399,18 +774,84 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -419,3 +860,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+3 -3
View File
@@ -16,9 +16,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md" readme = "README.md"
[workspace.dependencies] [workspace.dependencies]
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" } irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.2.0", path = "crates/irtt-client" } irtt-client = { version = "0.3.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" } irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
+37 -3
View File
@@ -10,13 +10,17 @@ It is not the upstream IRTT project. For the original implementation, protocol b
This repository currently provides: This repository currently provides:
- `irtt-cli`, a command-line IRTT-compatible client - `irtt-rs`, the canonical multi-applet binary
- `irtt-cli`, a stream/text IRTT-compatible client applet
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
- `irtt-client`, a Rust library for running client sessions and consuming events - `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 output modes - human, simple, machine-readable, and RTT-only stream output modes
- optional local summary statistics - optional local summary statistics
Server support is not implemented. Server support is not implemented.
The installed binary is intentionally not named `irtt` to avoid conflicting or
confusing overlap with upstream IRTT.
## Install ## Install
@@ -30,12 +34,24 @@ cd irtt-rs
cargo install --path crates/irtt-cli cargo install --path crates/irtt-cli
``` ```
The default install provides the normal client-oriented binaries, including
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
```sh
cargo install --path crates/irtt-cli --features tui
```
Or run it directly without installing: Or run it directly without installing:
```sh ```sh
cargo run -p irtt-cli -- <server> cargo run -p irtt-cli -- <server>
``` ```
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`,
`irtt-tui`, and future server applet names to it. Applet dispatch is based on
the invoked binary name.
## CLI usage ## CLI usage
Basic test: Basic test:
@@ -65,10 +81,22 @@ irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us irtt-cli <server> --output rtt-us
``` ```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
interactive probing. It defaults to continuous probing, equivalent to
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
session gracefully.
```sh
irtt-tui <server>
irtt-tui <server> --duration 30s
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
```
For available options: For available options:
```sh ```sh
irtt-cli --help irtt-cli --help
irtt-tui --help
``` ```
## Machine output ## Machine output
@@ -84,10 +112,16 @@ irtt-cli <server> --duration 0 --interval 250ms --output machine
Each line represents one client event and starts with an `event` field, for example: Each line represents one client event and starts with an `event` field, for example:
```text ```text
event=echo_reply seq=4 logical_seq=4 remote=203.0.113.10:2112 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_processing_us=300 dscp=0 ecn=0 kernel_rx_ns=none event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none
``` ```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored. Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
RTT fields are reported in microseconds. `raw_rtt_us` is the measured client
send-to-receive RTT. `effective_rtt_us` and `adjusted_rtt_us` are signed and may
be negative when server processing exceeds the raw RTT or timing correction
produces a negative adjusted value. When one-way delay fields are present,
`client_to_server_us` and `server_to_client_us` are also signed microseconds and
may be negative due to wall-clock skew.
For consumers that only need RTT values, use: For consumers that only need RTT values, use:
+26 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-cli" name = "irtt-cli"
version = "0.2.0" version = "0.3.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
@@ -11,13 +11,34 @@ readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"] keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"] categories = ["command-line-utilities", "network-programming"]
[lib]
doc = false
[[bin]]
name = "irtt-rs"
path = "src/main.rs"
[[bin]]
name = "irtt-cli"
path = "src/main.rs"
required-features = ["client"]
[[bin]]
name = "irtt-tui"
path = "src/main.rs"
required-features = ["tui"]
[features] [features]
default = ["stats"] default = ["client", "stats"]
stats = ["dep:irtt-stats"] client = ["dep:irtt-client", "dep:irtt-proto"]
stats = ["client", "dep:irtt-stats"]
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
[dependencies] [dependencies]
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.28.1", optional = true }
ctrlc = "3.5.2" ctrlc = "3.5.2"
irtt-client.workspace = true irtt-client = { workspace = true, optional = true }
irtt-proto.workspace = 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 }
+85
View File
@@ -0,0 +1,85 @@
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppletAvailability {
Client,
Tui,
Server,
None,
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0)
{
"irtt-cli" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub const fn default_applet_for_features(
client_enabled: bool,
tui_enabled: bool,
server_enabled: bool,
) -> AppletAvailability {
if client_enabled {
AppletAvailability::Client
} else if tui_enabled {
AppletAvailability::Tui
} else if server_enabled {
AppletAvailability::Server
} else {
AppletAvailability::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-cli"),
Some(RequestedApplet::Client)
);
assert_eq!(
detect_applet_from_argv0("irtt-tui"),
Some(RequestedApplet::Tui)
);
assert_eq!(
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn default_applet_prefers_client_then_tui() {
assert_eq!(
default_applet_for_features(true, true, false),
AppletAvailability::Client
);
assert_eq!(
default_applet_for_features(false, true, false),
AppletAvailability::Tui
);
assert_eq!(
default_applet_for_features(false, false, false),
AppletAvailability::None
);
}
}
+136
View File
@@ -0,0 +1,136 @@
use std::time::Duration;
use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig;
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(
long,
default_value = "10s",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
pub output: OutputMode,
/// Include extra fields in human output.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
/// Readable terminal output with a final summary.
Human,
/// Parseable full event fields.
Machine,
/// Simple key=value-ish event stream.
Simple,
/// RTT microseconds only.
RttUs,
}
impl OutputMode {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Human)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.to_client_config();
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn output_mode_summary_policy_is_human_only() {
assert!(OutputMode::Human.prints_summary());
assert!(!OutputMode::Simple.prints_summary());
assert!(!OutputMode::Machine.prints_summary());
assert!(!OutputMode::RttUs.prints_summary());
}
}
+12
View File
@@ -0,0 +1,12 @@
pub mod args;
pub mod output;
pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, OutputMode};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use run::run_stream;
+656
View File
@@ -0,0 +1,656 @@
use std::{
fmt::Write as _,
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_client::{
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
};
use super::args::OutputMode;
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
if matches!(event, ClientEvent::EchoSent { .. }) {
return None;
}
match mode {
OutputMode::RttUs => format_rtt_us(event),
OutputMode::Human => Some(format_human_event(event, None)),
OutputMode::Machine => Some(format_machine(event)),
OutputMode::Simple => Some(format_simple(event)),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HumanEventStats {
pub contributed_sample: bool,
pub ipdv_pairs: Vec<HumanIpdvPair>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HumanIpdvPair {
pub previous_seq: u32,
pub current_seq: u32,
pub rtt_ipdv: Duration,
pub send_ipdv: Option<Duration>,
pub receive_ipdv: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HumanOutputOptions {
pub verbose: bool,
}
#[cfg(feature = "stats")]
impl From<irtt_stats::EventStatsUpdate> for HumanEventStats {
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
Self {
contributed_sample: value.contributed_sample,
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
}
}
}
#[cfg(feature = "stats")]
impl From<irtt_stats::IpdvPairUpdate> for HumanIpdvPair {
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
Self {
previous_seq: value.previous_seq,
current_seq: value.current_seq,
rtt_ipdv: value.rtt_ipdv,
send_ipdv: value.send_ipdv,
receive_ipdv: value.receive_ipdv,
}
}
}
pub fn format_human_event(event: &ClientEvent, stats: Option<HumanEventStats>) -> String {
format_human_event_with_options(event, stats, HumanOutputOptions::default())
}
pub fn format_human_event_with_options(
event: &ClientEvent,
stats: Option<HumanEventStats>,
options: HumanOutputOptions,
) -> String {
let stats = stats.as_ref();
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
rtt,
server_timing,
one_way,
received_stats,
..
} => {
let mut out = format!("seq={seq}");
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " proc={}", format_duration(processing)).unwrap();
}
if options.verbose {
write_human_received_stats(&mut out, *received_stats);
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
one_way,
received_stats,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
}
write_human_received_stats(&mut out, *received_stats);
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn format_rtt_us(event: &ClientEvent) -> Option<String> {
match event {
ClientEvent::EchoReply { rtt, .. } => Some(signed_duration_us(rtt.effective).to_string()),
_ => None,
}
}
fn format_machine(event: &ClientEvent) -> String {
let mut out = String::new();
match event {
ClientEvent::SessionStarted {
remote,
token,
negotiated,
at,
} => {
write_common(&mut out, "session_started");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::NoTestCompleted {
remote,
negotiated,
at,
} => {
write_common(&mut out, "no_test_completed");
write_remote(&mut out, *remote);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::SessionClosed { remote, token, at } => {
write_common(&mut out, "session_closed");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
}
ClientEvent::EchoSent { .. } => {}
ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "echo_reply");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
write_rtt(&mut out, rtt);
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
}
ClientEvent::EchoLoss { seq, sent_at, .. } => {
write_common(&mut out, "loss");
write_seq(&mut out, *seq);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
out.push_str(" warning=loss");
}
ClientEvent::DuplicateReply {
seq,
remote,
received_at,
bytes: _,
} => {
write_common(&mut out, "duplicate");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
out.push_str(" warning=duplicate");
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "late");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write!(out, " highest_seen={highest_seen}").unwrap();
if let Some(sent_at) = sent_at {
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
}
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
if let Some(rtt) = rtt {
write_rtt(&mut out, rtt);
}
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
out.push_str(" warning=late");
}
ClientEvent::Warning { kind, message, at } => {
write_common(&mut out, "warning");
write_wall(&mut out, "at", at.wall);
write!(
out,
" warning_kind={} message={}",
warning_kind(*kind),
escape_value(message)
)
.unwrap();
}
}
out
}
fn format_simple(event: &ClientEvent) -> String {
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
remote,
rtt,
server_timing,
bytes: _,
..
} => {
let mut out = format!(
"reply seq={seq} remote={remote} rtt_us={}",
signed_duration_us(rtt.effective)
);
if rtt.adjusted.is_some() {
write!(out, " raw_rtt_us={}", duration_us(rtt.raw)).unwrap();
}
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt_us={}", signed_duration_us(rtt.effective)).unwrap();
}
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn write_common(out: &mut String, event: &str) {
write!(out, "event={event}").unwrap();
}
fn write_seq(out: &mut String, seq: u32) {
write!(out, " seq={seq}").unwrap();
}
fn write_remote(out: &mut String, remote: SocketAddr) {
write!(out, " remote={remote}").unwrap();
}
fn write_token(out: &mut String, token: u64) {
write!(out, " token={token:#x}").unwrap();
}
fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
write!(out, " {key}={}", duration.as_nanos()).unwrap();
}
}
fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
write!(
out,
" duration_ns={} interval_ns={} payload_length={}",
negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
)
.unwrap();
}
fn write_rtt(out: &mut String, rtt: &RttSample) {
write!(
out,
" raw_rtt_us={} effective_rtt_us={}",
duration_us(rtt.raw),
signed_duration_us(rtt.effective)
)
.unwrap();
if let Some(adjusted) = rtt.adjusted {
write!(out, " adjusted_rtt_us={}", signed_duration_us(adjusted)).unwrap();
}
}
fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
if let Some(timing) = timing {
write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
if let Some(processing) = timing.processing {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
}
}
fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
if let Some(one_way) = one_way {
if let Some(value) = one_way.client_to_server {
write!(out, " client_to_server_us={}", signed_duration_us(value)).unwrap();
}
if let Some(value) = one_way.server_to_client {
write!(out, " server_to_client_us={}", signed_duration_us(value)).unwrap();
}
}
}
fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received_count={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_received_window={window:#x}").unwrap();
}
}
}
fn write_packet_meta(out: &mut String, meta: PacketMeta) {
write_optional_u8(out, "traffic_class", meta.traffic_class);
write_optional_u8(out, "dscp", meta.dscp);
write_optional_u8(out, "ecn", meta.ecn);
match meta.kernel_rx_timestamp {
Some(timestamp) => write_wall(out, "kernel_rx_ns", timestamp),
None => write!(out, " kernel_rx_ns=none").unwrap(),
}
}
fn write_human_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
match one_way {
Some(one_way) => {
write!(
out,
" rd={} sd={}",
format_optional_signed_duration(one_way.server_to_client),
format_optional_signed_duration(one_way.client_to_server)
)
.unwrap();
}
None => out.push_str(" rd=n/a sd=n/a"),
}
}
fn write_human_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_window={window:#x}").unwrap();
}
}
}
fn format_human_ipdv(stats: Option<&HumanEventStats>, seq: u32) -> String {
let Some(stats) = stats else {
return "n/a".to_owned();
};
let pair = stats
.ipdv_pairs
.iter()
.find(|pair| pair.current_seq == seq)
.or_else(|| {
stats
.ipdv_pairs
.iter()
.find(|pair| pair.previous_seq == seq)
});
pair.map(|pair| format_duration(pair.rtt_ipdv))
.unwrap_or_else(|| "n/a".to_owned())
}
fn write_optional_u8(out: &mut String, key: &str, value: Option<u8>) {
match value {
Some(value) => write!(out, " {key}={value}").unwrap(),
None => write!(out, " {key}=none").unwrap(),
}
}
fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
if let Some(value) = value {
write!(out, " {key}={value}").unwrap();
}
}
fn duration_us(duration: Duration) -> u128 {
duration.as_micros()
}
fn signed_duration_us(duration: SignedDuration) -> i128 {
duration.as_micros()
}
fn format_optional_signed_duration(duration: Option<SignedDuration>) -> String {
duration
.map(format_signed_duration)
.unwrap_or_else(|| "n/a".to_owned())
}
fn format_duration(duration: Duration) -> String {
format_ns(duration.as_nanos() as f64)
}
fn format_signed_duration(duration: SignedDuration) -> String {
format_signed_ns(duration.as_nanos() as f64)
}
fn format_signed_ns(ns: f64) -> String {
if ns < 0.0 {
format!("-{}", format_ns(-ns))
} else {
format_ns(ns)
}
}
fn format_ns(ns: f64) -> String {
if ns < 1_000.0 {
format!("{ns:.0}ns")
} else if ns < 1_000_000.0 {
format!("{:.1}µs", ns / 1_000.0)
} else if ns < 1_000_000_000.0 {
format!("{:.1}ms", ns / 1_000_000.0)
} else {
format!("{:.3}s", ns / 1_000_000_000.0)
}
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
WarningKind::WrongToken => "wrong_token",
WarningKind::UntrackedReply => "untracked_reply",
_ => "unknown",
}
}
fn escape_value(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(' ', "\\s")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event() -> ClientEvent {
ClientEvent::EchoReply {
seq: 7,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: Some(1_000),
receive_mono_ns: Some(2_000),
send_wall_ns: Some(301_000),
send_mono_ns: Some(302_000),
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(400_000)),
server_to_client: Some(SignedDuration::from_nanos(500_000)),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(9),
window: Some(0x7),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn rtt_us_prints_only_effective_reply_rtt() {
assert_eq!(
format_event(&reply_event(), OutputMode::RttUs),
Some("1200".to_owned())
);
assert_eq!(
format_event(
&ClientEvent::Warning {
kind: WarningKind::WrongToken,
message: "bad".to_owned(),
at: ClientTimestamp::now()
},
OutputMode::RttUs
),
None
);
}
#[test]
fn machine_prints_stable_key_value_fields() {
let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
assert!(line.starts_with("event=echo_reply "));
assert!(line.contains("seq=7"));
assert!(line.contains("remote=127.0.0.1:2112"));
assert!(line.contains("client_send_wall_ns=1000000000"));
assert!(line.contains("client_receive_wall_ns=1001500000"));
assert!(line.contains("raw_rtt_us=1500"));
assert!(line.contains("adjusted_rtt_us=1200"));
assert!(line.contains("effective_rtt_us=1200"));
assert!(line.contains("server_processing_us=300"));
assert!(line.contains("server_received_count=9"));
}
#[test]
fn simple_and_human_use_readable_reply_lines() {
assert_eq!(
format_event(&reply_event(), OutputMode::Simple),
Some(
"reply seq=7 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
.to_owned()
)
);
let human = format_event(&reply_event(), OutputMode::Human).unwrap();
assert!(human.starts_with("seq=7 rtt=1.2ms"));
assert!(human.contains("rd=500.0µs"));
assert!(human.contains("sd=400.0µs"));
assert!(human.contains("ipdv=n/a"));
assert!(human.contains("proc=300.0µs"));
assert!(!human.contains("rtt_us="));
}
#[test]
fn echo_sent_is_not_formatted_for_stream_outputs() {
let ts = test_timestamp(Duration::from_secs(1));
let event = ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: ts.mono,
sent_at: ts,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
};
assert!(format_event(&event, OutputMode::RttUs).is_none());
assert!(format_event(&event, OutputMode::Human).is_none());
assert!(format_event(&event, OutputMode::Machine).is_none());
assert!(format_event(&event, OutputMode::Simple).is_none());
}
}
+485
View File
@@ -0,0 +1,485 @@
use std::{
io::{self, Write},
sync::atomic::AtomicBool,
};
use irtt_client::ClientEvent;
#[cfg(all(test, feature = "stats"))]
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{
args::{ClientArgs, OutputMode},
output::{format_event, format_human_event_with_options, HumanOutputOptions},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested, session::should_print_final_summary, ClientSession,
};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
#[cfg(feature = "stats")]
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
#[cfg(feature = "stats")]
const MIB: u64 = 1024 * 1024;
#[cfg(feature = "stats")]
const GIB: u64 = 1024 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
eprintln!("{warning}");
}
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
stream_output.print_events(&events)?;
let mut interrupted = false;
while session.should_continue(shutdown_requested) {
let events = session.step(shutdown_requested)?;
stream_output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
session.sleep_until_next_send();
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if session.should_drain_final(interrupted) {
session.drain_final(|events| {
let _ = stream_output.print_events(events);
})?;
}
let events = session.poll_timeouts()?;
stream_output.print_events(&events)?;
let events = session.close()?;
stream_output.print_events(&events)?;
let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
stream_output.print_summary()?;
stream_output.out.flush()?;
Ok(())
}
struct StreamOutput<'a, W: Write> {
mode: OutputMode,
human_options: HumanOutputOptions,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = crate::cmd::client::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
#[cfg(feature = "stats")]
let human_stats = stats_update.into();
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
Some(format_human_event_with_options(
event,
Some(human_stats),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
show_running_only_note: self.show_running_only_summary_note,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
#[cfg(feature = "stats")]
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
}
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let expected_probes = expected_probe_count(args.duration, args.interval);
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = format_bytes_for_warning(estimated_bytes);
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!(
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
))
}
#[cfg(feature = "stats")]
fn format_bytes_for_warning(bytes: u64) -> String {
if bytes >= GIB {
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
fn cli_args(args: &[&str]) -> ClientArgs {
let mut argv = vec!["irtt-rs"];
argv.extend_from_slice(args);
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_event(&reply_event(1, 1200)).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("irtt-rs summary"));
assert!(rendered.contains("medians unavailable"));
assert!(rendered.contains("continuous mode"));
assert!(rendered.contains("packets:"));
assert!(rendered.contains("received=1"));
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: SignedDuration::from_nanos(1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
for args in [
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
cli_args(&[
"--duration",
"1000ms",
"--interval",
"1ms",
"127.0.0.1:2112",
]),
] {
assert_eq!(finite_stats_memory_warning(&args), None);
}
for (threshold, size, guidance) in [
(
FINITE_STATS_MEMORY_WARNING_BYTES,
"about 128 MiB",
"bounded-memory long-running tests",
),
(
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
"about 512 MiB",
"shortening the run",
),
(
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
"about 1 GiB",
"memory-constrained systems",
),
] {
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
let args = cli_args(&[
"--duration",
&format!("{expected_probes}ms"),
"--interval",
"1ms",
"127.0.0.1:2112",
]);
let warning = finite_stats_memory_warning(&args).unwrap();
assert!(warning.contains(size));
assert!(warning.contains(guidance));
}
}
#[test]
fn expected_probe_count_rounds_up() {
assert_eq!(
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
2
);
}
#[test]
fn estimate_finite_stats_memory_bytes_saturates() {
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
}
}
@@ -9,6 +9,7 @@ pub fn format_summary(summary: &Snapshot) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions { pub struct SummaryFormatOptions {
pub verbose: bool, pub verbose: bool,
pub show_running_only_note: bool,
} }
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String { pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
@@ -18,6 +19,13 @@ pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOpt
writeln!(out).unwrap(); writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap(); writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap(); writeln!(out).unwrap();
writeln!( writeln!(
out, out,
@@ -198,10 +206,8 @@ mod tests {
received_at, received_at,
rtt: RttSample { rtt: RttSample {
raw: Duration::from_micros(1500), raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)), adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: Duration::from_micros(1200), effective: SignedDuration::from_nanos(1_200_000),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
}, },
server_timing: Some(ServerTiming { server_timing: Some(ServerTiming {
receive_wall_ns: None, receive_wall_ns: None,
@@ -232,6 +238,7 @@ mod tests {
assert!(output.contains("10.0µs")); assert!(output.contains("10.0µs"));
assert!(output.contains("timer error")); assert!(output.contains("timer error"));
assert!(output.contains("2.0µs")); assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
} }
#[test] #[test]
@@ -245,10 +252,8 @@ mod tests {
received_at, received_at,
rtt: RttSample { rtt: RttSample {
raw: Duration::from_micros(1500), raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)), adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: Duration::from_micros(1200), effective: SignedDuration::from_nanos(1_200_000),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
}, },
server_timing: None, server_timing: None,
one_way: None, one_way: None,
@@ -259,13 +264,32 @@ mod tests {
let output = format_summary_with_options( let output = format_summary_with_options(
&collector.snapshot(), &collector.snapshot(),
SummaryFormatOptions { verbose: true }, SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
); );
assert!(output.contains("raw RTT")); assert!(output.contains("raw RTT"));
assert!(output.contains("adjusted RTT")); assert!(output.contains("adjusted RTT"));
} }
#[test]
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test] #[test]
fn signed_stats_can_format_negative_values() { fn signed_stats_can_format_negative_values() {
let stats = TimeStats { let stats = TimeStats {
+7
View File
@@ -0,0 +1,7 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "tui")]
pub mod tui;
// Future command applets, such as a server applet, belong here behind their
// own feature gate.
+134
View File
@@ -0,0 +1,134 @@
use std::time::Duration;
use clap::Parser;
use irtt_client::ClientConfig;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{parse_test_duration, CommonClientArgs};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(
long,
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::client::ClientArgs;
use clap::{CommandFactory, Parser};
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.to_client_config().duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.to_client_config().duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn shared_client_options_match_between_stream_and_tui_parsers() {
let shared = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let client = parse_client(&shared).unwrap().to_client_config();
let tui = parse(&shared).unwrap().to_client_config();
assert_eq!(client.server_addr, tui.server_addr);
assert_eq!(client.duration, tui.duration);
assert_eq!(client.interval, tui.interval);
assert_eq!(client.length, tui.length);
assert_eq!(client.received_stats, tui.received_stats);
assert_eq!(client.stamp_at, tui.stamp_at);
assert_eq!(client.clock, tui.clock);
assert_eq!(client.dscp, tui.dscp);
assert_eq!(client.hmac_key, tui.hmac_key);
assert_eq!(client.server_fill, tui.server_fill);
assert_eq!(client.negotiation_policy, tui.negotiation_policy);
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
+216
View File
@@ -0,0 +1,216 @@
use std::{
io,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_stats::StatsCollector;
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{is_shutdown_requested, ClientSession},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
if continuous {
irtt_stats::StatsConfig::continuous()
} else {
irtt_stats::StatsConfig::finite()
}
}
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::new(TuiConfig::from_args(&args));
let mut stats = StatsCollector::new(stats_config(continuous));
let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
Ok(session) => session,
Err(err) => {
state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
return Err(Box::new(err));
}
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
state.process_events(&events, &mut stats);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut interrupted = false;
// Keep this in lockstep with run_stream: send due probes, drain available
// replies, poll timeouts, sleep toward the next absolute send deadline,
// then perform the same final drain, timeout poll, and close sequence.
while session.should_continue(shutdown_requested) {
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
break;
}
let events = session.step(shutdown_requested)?;
state.process_events(&events, &mut stats);
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
wait_for_tui_activity(
session.next_send_deadline(),
&mut next_render,
&mut state,
&stats,
&mut terminal,
shutdown_requested,
)?;
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if session.should_drain_final(interrupted) {
state.set_status(TuiStatus::Draining);
let mut drain_render = Instant::now();
session.drain_final(|events| {
state.process_events(events, &mut stats);
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false);
})?;
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
let events = session.poll_timeouts()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let events = session.close()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
Ok(())
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
while event::poll(Duration::ZERO)? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.cycle_graph_mode();
force_render = true;
}
KeyCode::Char('f') => {
state.toggle_full_graph();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
stats: &StatsCollector,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if !should_render(now, *next_render, state.paused, force) {
return Ok(());
}
terminal.draw(state, &stats.snapshot())?;
*next_render = now + RENDER_INTERVAL;
Ok(())
}
fn wait_for_tui_activity(
next_send_deadline: Option<Instant>,
next_render: &mut Instant,
state: &mut TuiState,
stats: &StatsCollector,
terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool,
) -> io::Result<()> {
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
if wait_for.is_zero() || !event::poll(wait_for)? {
return Ok(());
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, stats, next_render, true)?;
}
Ok(())
}
fn tui_wait_duration(
next_send_deadline: Option<Instant>,
next_render: Instant,
paused: bool,
) -> Duration {
let now = Instant::now();
let send_wait = next_send_deadline
.map(|deadline| deadline.saturating_duration_since(now))
.unwrap_or(IDLE_SLEEP);
let render_wait = if paused {
send_wait
} else {
next_render.saturating_duration_since(now)
};
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+59 -446
View File
@@ -1,40 +1,19 @@
use std::{ use std::{
io::{self, Write}, env,
ffi::OsString,
process::ExitCode, process::ExitCode,
sync::{ sync::{atomic::AtomicBool, Arc},
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
}; };
use clap::Parser;
use irtt_cli::{ use irtt_cli::{
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode, applet::{
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
},
signal::install_signal_handler,
}; };
use irtt_client::{Client, ClientEvent, OpenOutcome, RecvBudget};
#[cfg(test)]
use irtt_client::ClientTimestamp;
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
fn main() -> ExitCode { fn main() -> ExitCode {
let args = CliArgs::parse(); match run_from_env() {
let shutdown_requested = Arc::new(AtomicBool::new(false));
if let Err(err) = install_signal_handler(Arc::clone(&shutdown_requested)) {
eprintln!("irtt-rs: failed to install signal handler: {err}");
return ExitCode::FAILURE;
}
match run(args, shutdown_requested.as_ref()) {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(err) => { Err(err) => {
eprintln!("irtt-rs: {err}"); eprintln!("irtt-rs: {err}");
@@ -43,437 +22,71 @@ fn main() -> ExitCode {
} }
} }
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> { fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
ctrlc::set_handler(move || { let argv: Vec<OsString> = env::args_os().collect();
shutdown_requested.store(true, Ordering::Relaxed); let argv0 = argv
}) .first()
} .and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> { let requested = match detect_applet_from_argv0(argv0) {
let mode = args.output; Some(applet) => applet,
let continuous = args.is_continuous(); None => default_requested_applet()?,
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut output = EventOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_finite_summary: !continuous,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
}; };
if is_shutdown_requested(shutdown_requested) { let shutdown_requested = Arc::new(AtomicBool::new(false));
return Ok(()); install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => Err("server applet is not available in this build".into()),
} }
let mut client = Client::connect(args.to_client_config())?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let open = client.open()?;
output.print_event(open_event(&open))?;
let mut interrupted = false;
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
let events = client.send_probe()?;
output.print_events(&events)?;
}
let events = client.recv_available(RECV_BUDGET)?;
output.print_events(&events)?;
let events = client.poll_timeouts()?;
output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
sleep_until_next_send(client.next_send_deadline());
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if should_drain_final(continuous, interrupted) {
drain_final_replies(&mut client, &mut output)?;
}
let events = client.poll_timeouts()?;
output.print_events(&events)?;
let events = client.close()?;
output.print_events(&events)?;
output.print_summary()?;
output.out.flush()?;
Ok(())
} }
fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool { fn default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
shutdown_requested.load(Ordering::Relaxed) 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()),
}
} }
fn should_continue_run( #[cfg(feature = "client")]
continuous: bool, fn run_client_applet(
next_send_deadline: Option<Instant>, argv: Vec<OsString>,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> bool {
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
fn drain_final_replies<W: Write>(
client: &mut Client,
output: &mut EventOutput<'_, W>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + final_drain_duration(client.probe_timeout()); use clap::Parser;
loop {
let mut printed = false;
let events = client.recv_available(RECV_BUDGET)?; let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
printed |= !events.is_empty(); irtt_cli::cmd::client::run_stream(args, shutdown_requested)
output.print_events(&events)?;
let events = client.poll_timeouts()?;
printed |= !events.is_empty();
output.print_events(&events)?;
if client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !printed {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
} }
fn sleep_until_next_send(deadline: Option<Instant>) { #[cfg(not(feature = "client"))]
let sleep_for = match deadline { fn run_client_applet(
Some(deadline) => deadline _argv: Vec<OsString>,
.saturating_duration_since(Instant::now()) _shutdown_requested: &AtomicBool,
.min(MAX_SLEEP), ) -> Result<(), Box<dyn std::error::Error>> {
None => Duration::from_millis(1), Err("client applet is not available; rebuild with the client feature".into())
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
} }
struct EventOutput<'a, W: Write> { #[cfg(feature = "tui")]
mode: irtt_cli::OutputMode, fn run_tui_applet(
human_options: HumanOutputOptions, argv: Vec<OsString>,
print_finite_summary: bool, shutdown_requested: &AtomicBool,
out: &'a mut W, ) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "stats")] use clap::Parser;
stats: &'a mut StatsCollector,
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
} }
impl<W: Write> EventOutput<'_, W> { #[cfg(not(feature = "tui"))]
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> { fn run_tui_applet(
for event in events { _argv: Vec<OsString>,
self.print_event(event)?; _shutdown_requested: &AtomicBool,
} ) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Err("TUI applet is not available; rebuild with the tui feature".into())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = irtt_cli::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
Some(format_human_event_with_options(
event,
Some(stats_update.into()),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_finite_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
irtt_cli::summary::format_summary_with_options(
&self.stats.snapshot(),
irtt_cli::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
#[cfg(test)]
mod shutdown_tests {
use super::*;
#[test]
fn shutdown_flag_stops_run_loop() {
let shutdown = AtomicBool::new(false);
assert!(should_continue_run(true, Some(Instant::now()), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_continue_run(true, Some(Instant::now()), &shutdown));
}
#[test]
fn shutdown_flag_suppresses_due_probe_send() {
let shutdown = AtomicBool::new(false);
let due = Instant::now() - Duration::from_millis(1);
assert!(should_send_probe(Some(due), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_send_probe(Some(due), &shutdown));
}
#[test]
fn interrupted_continuous_run_uses_final_drain_before_close() {
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
assert!(should_drain_final(false, false));
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use irtt_client::{PacketMeta, RttSample, SignedDuration};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::UNIX_EPOCH,
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: Duration::from_micros(rtt_us),
adjusted_signed: None,
effective_signed: SignedDuration {
ns: i128::from(rtt_us) * 1_000,
},
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: Duration::from_micros(1200),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_finite_summary: true,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_output_does_not_print_finite_summary() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: Duration::from_micros(1),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
} }
+223
View File
@@ -0,0 +1,223 @@
use std::time::Duration;
use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Debug, Clone, clap::Args)]
pub struct CommonClientArgs {
/// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
/// UDP payload length.
#[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32,
/// HMAC key.
#[arg(long)]
pub hmac: Option<String>,
/// Clock mode to request.
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
pub clock: ClockArg,
/// Timestamp mode to request.
#[arg(
long = "tstamp",
visible_alias = "timestamps",
value_enum,
value_name = "MODE",
default_value_t = TimestampArg::Both
)]
pub tstamp: TimestampArg,
/// Received-stats mode to request.
#[arg(
long = "stats",
value_enum,
default_value_t = ReceivedStatsArg::Both,
help = "Server received-stats negotiation mode"
)]
pub stats: ReceivedStatsArg,
/// Server payload fill string to request, up to 32 bytes.
#[arg(
long = "sfill",
visible_alias = "server-fill",
value_name = "STRING",
value_parser = parse_server_fill
)]
pub server_fill: Option<String>,
/// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
pub dscp: u8,
/// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>,
/// Accept safe server restrictions during negotiation.
#[arg(long)]
pub loose: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg {
Wall,
Monotonic,
Both,
}
impl From<ClockArg> for Clock {
fn from(value: ClockArg) -> Self {
match value {
ClockArg::Wall => Self::Wall,
ClockArg::Monotonic => Self::Monotonic,
ClockArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampArg {
None,
Send,
Receive,
Both,
Midpoint,
}
impl From<TimestampArg> for StampAt {
fn from(value: TimestampArg) -> Self {
match value {
TimestampArg::None => Self::None,
TimestampArg::Send => Self::Send,
TimestampArg::Receive => Self::Receive,
TimestampArg::Both => Self::Both,
TimestampArg::Midpoint => Self::Midpoint,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReceivedStatsArg {
None,
Count,
Window,
Both,
}
impl From<ReceivedStatsArg> for ReceivedStats {
fn from(value: ReceivedStatsArg) -> Self {
match value {
ReceivedStatsArg::None => Self::None,
ReceivedStatsArg::Count => Self::Count,
ReceivedStatsArg::Window => Self::Window,
ReceivedStatsArg::Both => Self::Both,
}
}
}
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Ok(Duration::ZERO);
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
}
pub fn parse_length(input: &str) -> Result<u32, String> {
let length: u32 = input
.parse()
.map_err(|_| format!("invalid packet length {input:?}"))?;
if length > MAX_UDP_PAYLOAD_LENGTH {
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
}
Ok(length)
}
pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
}
let len = input.len();
if len > MAX_SERVER_FILL_BYTES {
return Err(format!(
"server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
));
}
Ok(input.to_owned())
}
pub fn parse_dscp(input: &str) -> Result<u8, String> {
let value: u8 = input
.parse()
.map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
if value > MAX_DSCP_CODEPOINT {
return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
}
Ok(value)
}
pub fn parse_ttl(input: &str) -> Result<u32, String> {
let value: u32 = input
.parse()
.map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
if value == 0 || value > MAX_TTL {
return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
}
Ok(value)
}
@@ -0,0 +1,48 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
impl CommonClientArgs {
pub fn to_client_config(&self, server: &str, duration: Duration) -> ClientConfig {
ClientConfig {
server_addr: server.to_owned(),
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
server_fill: self.server_fill.clone(),
negotiation_policy: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
socket_config: SocketConfig {
ttl: self.ttl,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
+10
View File
@@ -0,0 +1,10 @@
pub mod args;
pub mod config;
pub mod session;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
@@ -0,0 +1,162 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
pub struct ClientSession {
client: Client,
continuous: bool,
}
impl ClientSession {
pub fn connect(
config: ClientConfig,
continuous: bool,
) -> Result<Self, irtt_client::ClientError> {
Ok(Self {
client: Client::connect(config)?,
continuous,
})
}
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
Ok(vec![open_event(&self.client.open()?).clone()])
}
pub fn step(
&mut self,
shutdown_requested: &AtomicBool,
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
let mut events = Vec::new();
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
events.extend(self.client.send_probe()?);
}
events.extend(self.client.recv_available(RECV_BUDGET)?);
events.extend(self.client.poll_timeouts()?);
Ok(events)
}
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& (self.continuous || self.client.next_send_deadline().is_some())
}
pub fn should_drain_final(&self, interrupted: bool) -> bool {
should_drain_final(self.continuous, interrupted)
}
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
where
F: FnMut(&[ClientEvent]),
{
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
loop {
let mut received = false;
let events = self.client.recv_available(RECV_BUDGET)?;
received |= !events.is_empty();
on_events(&events);
let events = self.client.poll_timeouts()?;
received |= !events.is_empty();
on_events(&events);
if self.client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !received {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.poll_timeouts()
}
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.close()
}
pub fn next_send_deadline(&self) -> Option<Instant> {
self.client.next_send_deadline()
}
pub fn probe_timeout(&self) -> Duration {
self.client.probe_timeout()
}
pub fn sleep_until_next_send(&self) {
let sleep_for = match self.next_send_deadline() {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
#[test]
fn should_drain_final_for_finite_or_interrupted_runs() {
assert!(should_drain_final(false, false));
assert!(should_drain_final(false, true));
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(feature = "client")]
pub mod client;
+10
View File
@@ -0,0 +1,10 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.2.0" version = "0.3.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
@@ -14,7 +14,7 @@ categories = ["network-programming"]
[dependencies] [dependencies]
irtt-proto.workspace = true irtt-proto.workspace = true
libc = { version = "0.2.186", optional = true } libc = { version = "0.2.186", optional = true }
socket2 = { version = "0.6.3", features = ["all"] } socket2 = { version = "0.6.4", features = ["all"] }
thiserror = "2.0.18" thiserror = "2.0.18"
[features] [features]
+49 -46
View File
@@ -32,6 +32,12 @@ use crate::{
const MAX_OPEN_PACKET_SIZE: usize = 512; const MAX_OPEN_PACKET_SIZE: usize = 512;
const MIN_RECV_BUFFER_SIZE: usize = 2048; const MIN_RECV_BUFFER_SIZE: usize = 2048;
/// Low-level synchronous IRTT client.
///
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
/// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use [`ManagedClient`](crate::ManagedClient)
/// instead.
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
config: ClientConfig, config: ClientConfig,
@@ -45,6 +51,11 @@ pub struct Client {
} }
impl Client { impl Client {
/// Resolve the configured server and create a connected UDP socket.
///
/// This validates local configuration and prepares the open request, but it
/// does not contact the server. Call [`open`](Self::open) to perform the
/// IRTT open exchange.
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> { 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 { if config.max_pending_probes == 0 {
@@ -73,6 +84,11 @@ impl Client {
}) })
} }
/// Perform the IRTT open exchange.
///
/// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`].
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> { pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
match self.phase { match self.phase {
ClientPhase::Connected => {} ClientPhase::Connected => {}
@@ -127,6 +143,10 @@ impl Client {
} }
} }
/// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
///
/// The close event means the client has sent its close packet and stopped
/// 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 token = match self.phase {
ClientPhase::Open { token } => token, ClientPhase::Open { token } => token,
@@ -153,6 +173,8 @@ impl Client {
}]) }])
} }
/// Return the monotonic deadline for the next probe send, if another probe
/// is scheduled.
pub fn next_send_deadline(&self) -> Option<Instant> { pub fn next_send_deadline(&self) -> Option<Instant> {
let session = self.session.as_ref()?; let session = self.session.as_ref()?;
if session.sending_done { if session.sending_done {
@@ -161,14 +183,24 @@ impl Client {
Some(session.next_send_at) Some(session.next_send_at)
} }
/// 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.config.probe_timeout
} }
/// Send one echo probe if the negotiated run is still active.
///
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
/// list when the run duration has elapsed and no further probe should be
/// sent.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(None) self.send_probe_inner(None)
} }
/// Receive and classify at most one datagram from the socket.
///
/// Returns an empty event list when the socket read would block or times
/// out. Malformed or unrelated datagrams are reported as warning events.
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) self.recv_once_inner(None)
} }
@@ -315,6 +347,8 @@ impl Client {
self.process_echo_reply(reply, packet_len, now, datagram.meta) self.process_echo_reply(reply, packet_len, now, datagram.meta)
} }
/// Receive and classify datagrams until a receive produces no events or the
/// receive budget is exhausted.
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> { pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
let mut all_events = Vec::new(); let mut all_events = Vec::new();
for _ in 0..budget.max_packets { for _ in 0..budget.max_packets {
@@ -327,12 +361,6 @@ impl Client {
Ok(all_events) Ok(all_events)
} }
#[cfg(test)]
pub(crate) fn recv_buffer_size(&self) -> usize {
recv_buffer_size(self.config.hmac_key.is_some(), self.negotiated.as_ref())
.expect("test params must have a non-negative packet length")
}
/// Polls for probes that have timed out as of the current monotonic time. /// Polls for probes that have timed out as of the current monotonic time.
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.poll_timeouts_at(Instant::now()) self.poll_timeouts_at(Instant::now())
@@ -372,6 +400,11 @@ impl Client {
Ok(events) Ok(events)
} }
/// Return whether the current run has completed.
///
/// A normal run is complete once no more probes will be sent and all
/// pending probes have either replied or timed out. No-test and closed
/// 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 { let Some(session) = self.session.as_ref() else {
return matches!( return matches!(
@@ -394,24 +427,6 @@ impl Client {
.map_or(0, |session| session.packets_sent) .map_or(0, |session| session.packets_sent)
} }
#[cfg(test)]
fn process_received_packet(
&mut self,
packet: &[u8],
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> {
let packet_len = packet.len();
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)
}
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> { fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
let negotiated = self let negotiated = self
.negotiated .negotiated
@@ -457,7 +472,7 @@ impl Client {
.is_some_and(|h| sequence_is_before(wire_seq, h)); .is_some_and(|h| sequence_is_before(wire_seq, h));
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq); let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(session, wire_seq); update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq); session.completed.insert(wire_seq);
let mut events = Vec::new(); let mut events = Vec::new();
@@ -494,7 +509,7 @@ impl Client {
} }
Ok(events) Ok(events)
} else if session.completed.contains(wire_seq) { } else if session.completed.contains(wire_seq) {
update_highest_received(session, wire_seq); update_highest_received(&mut session.highest_received_seq, wire_seq);
Ok(vec![ClientEvent::DuplicateReply { Ok(vec![ClientEvent::DuplicateReply {
seq: wire_seq, seq: wire_seq,
remote: self.remote, remote: self.remote,
@@ -507,7 +522,7 @@ impl Client {
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps); let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
let received_stats = build_received_stats(&reply); let received_stats = build_received_stats(&reply);
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq); let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(session, wire_seq); update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq); session.completed.insert(wire_seq);
let mut events = vec![ClientEvent::LateReply { let mut events = vec![ClientEvent::LateReply {
@@ -688,14 +703,10 @@ impl Client {
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> { fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(Some(ts)) self.send_probe_inner(Some(ts))
} }
fn recv_once_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(Some(ts))
}
} }
fn update_highest_received(session: &mut ActiveSession, wire_seq: u32) { fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
session.highest_received_seq = Some(session.highest_received_seq.map_or(wire_seq, |h| { *highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
if sequence_is_after(wire_seq, h) { if sequence_is_after(wire_seq, h) {
wire_seq wire_seq
} else { } else {
@@ -755,22 +766,14 @@ fn compute_rtt(
let server_processing = compute_server_processing(ts); let server_processing = compute_server_processing(ts);
let adjusted = server_processing.and_then(|sp| raw.checked_sub(sp)); let adjusted = server_processing
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
let effective = adjusted.unwrap_or(raw); let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
let adjusted_signed = server_processing.map(|sp| SignedDuration {
ns: duration_ns_i128(raw) - duration_ns_i128(sp),
});
let effective_signed = adjusted_signed.unwrap_or(SignedDuration {
ns: duration_ns_i128(raw),
});
RttSample { RttSample {
raw, raw,
adjusted, adjusted,
effective, effective,
adjusted_signed,
effective_signed,
} }
} }
@@ -825,11 +828,11 @@ fn compute_one_way(
let c2s = server_recv_wall let c2s = server_recv_wall
.zip(client_send_ns) .zip(client_send_ns)
.and_then(|(srv, cli)| srv.checked_sub(cli)) .and_then(|(srv, cli)| srv.checked_sub(cli))
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos)); .map(|d| SignedDuration::from_nanos(i128::from(d)));
let s2c = client_recv_ns let s2c = client_recv_ns
.zip(server_send_wall) .zip(server_send_wall)
.and_then(|(cli, srv)| cli.checked_sub(srv)) .and_then(|(cli, srv)| cli.checked_sub(srv))
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos)); .map(|d| SignedDuration::from_nanos(i128::from(d)));
if c2s.is_none() && s2c.is_none() { if c2s.is_none() && s2c.is_none() {
return None; return None;
@@ -68,31 +68,6 @@ fn send_probe_fails_after_close() {
server.join(); server.join();
} }
#[test]
fn close_clears_timed_out_metadata() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
client.poll_timeouts().unwrap();
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
client.close().unwrap();
assert!(client.session.is_none());
server.join();
}
#[test] #[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() { fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params(); let params = default_params();
+80 -98
View File
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
} }
#[test] #[test]
fn params_from_config_accepts_udp_payload_length_boundary_values() { fn params_from_config_accepts_boundary_values() {
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] { for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
let config = ClientConfig { let config = ClientConfig {
length, length,
@@ -62,18 +62,12 @@ fn params_from_config_accepts_udp_payload_length_boundary_values() {
i64::from(length) i64::from(length)
); );
} }
}
#[test]
fn params_from_config_rejects_oversized_udp_payload_length() {
let config = ClientConfig { let config = ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1, dscp: 63,
..ClientConfig::default() ..ClientConfig::default()
}; };
assert!(matches!( assert_eq!(params_from_config(&config).unwrap().dscp, 63);
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
));
} }
#[test] #[test]
@@ -86,97 +80,85 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
} }
#[test] #[test]
fn params_from_config_rejects_zero_finite_duration() { fn params_from_config_rejects_invalid_values() {
let config = ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration must be greater than zero; use None for continuous mode"
));
}
#[test]
fn params_from_config_rejects_zero_interval() {
let config = ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
));
}
#[test]
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap(); let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let config = ClientConfig { let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)), let cases = [
..ClientConfig::default() (
}; "oversized UDP payload",
assert!(matches!( ClientConfig {
params_from_config(&config), length: MAX_UDP_PAYLOAD_LENGTH + 1,
Err(ClientError::InvalidConfig { reason }) ..ClientConfig::default()
if reason == "duration is too large to encode as nanoseconds" },
)); "packet length",
),
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
},
"duration must be greater than zero; use None for continuous mode",
),
(
"zero interval",
ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
},
"interval must be greater than zero",
),
(
"duration nanosecond overflow",
ClientConfig {
duration: Some(too_large),
..ClientConfig::default()
},
"duration is too large to encode as nanoseconds",
),
(
"interval nanosecond overflow",
ClientConfig {
interval: too_large,
..ClientConfig::default()
},
"interval is too large to encode as nanoseconds",
),
(
"invalid DSCP codepoint",
ClientConfig {
dscp: 64,
..ClientConfig::default()
},
"dscp",
),
(
"empty server fill",
ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
(
"oversized server fill",
ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
];
let config = ClientConfig { for (name, config, expected_reason) in cases {
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1), assert!(
..ClientConfig::default() matches!(
}; params_from_config(&config),
assert!(matches!( Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
params_from_config(&config), ),
Err(ClientError::InvalidConfig { reason }) "{name} should fail with InvalidConfig containing {expected_reason:?}"
if reason == "interval is too large to encode as nanoseconds" );
)); }
}
#[test]
fn params_from_config_accepts_max_dscp_codepoint() {
let config = ClientConfig {
dscp: 63,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
fn params_from_config_rejects_invalid_dscp_codepoint() {
let config = ClientConfig {
dscp: 64,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_empty_server_fill() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_oversized_server_fill() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
} }
#[test] #[test]
@@ -1,20 +1,5 @@
use super::*; use super::*;
#[test]
fn no_test_open_close_succeeds_on_open_reply_close() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
assert!(matches!(client.close(), Err(ClientError::NotOpen)));
server.join();
}
#[test] #[test]
fn no_test_success_validates_params() { fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -1,16 +1,5 @@
use super::*; use super::*;
#[test]
fn address_resolution_connects_to_local_fake_server() {
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let client = Client::connect(default_test_config(server.addr)).unwrap();
client.socket.send(b"ping").unwrap();
assert_eq!(server.rx.recv().unwrap(), b"ping");
server.join();
}
#[test] #[test]
fn successful_open_handshake() { fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -20,7 +9,6 @@ fn successful_open_handshake() {
let negotiated = assert_open_started(client.open().unwrap()); let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params); assert_eq!(negotiated.params, params);
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
server.join(); server.join();
} }
@@ -69,27 +57,6 @@ fn open_timeout_after_all_timeouts() {
server.join(); server.join();
} }
#[test]
fn open_restores_configured_read_timeout_after_timeout() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(450)),
..crate::SocketConfig::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let start = std::time::Instant::now();
let mut buf = [0_u8; 1];
assert!(client.socket.recv(&mut buf).is_err());
assert!(start.elapsed() >= Duration::from_millis(350));
server.join();
}
#[test] #[test]
fn protocol_version_mismatch_fails() { fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
use super::*;
#[test]
fn recv_available_drains_burst_replies() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let events = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(events.len(), 3);
for (seq, event) in events.iter().enumerate() {
assert!(matches!(
event,
ClientEvent::EchoReply {
seq: actual_seq,
..
} if *actual_seq == seq as u32
));
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_available_respects_packet_budget() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let first = client
.recv_available(RecvBudget { max_packets: 1 })
.unwrap();
assert_eq!(first.len(), 1);
assert!(matches!(&first[0], ClientEvent::EchoReply { seq: 0, .. }));
let second = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(second.len(), 1);
assert!(matches!(&second[0], ClientEvent::EchoReply { seq: 1, .. }));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,29 @@
use super::*;
use support::echo_packet_len;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
fn assert_packet_meta_unavailable(packet_meta: &crate::event::PacketMeta) {
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn metadata_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping metadata assertion because kernel did not provide traffic class metadata");
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping kernel timestamp assertion because kernel did not provide SCM_TIMESTAMPNS");
}
mod burst_recv;
mod packet_length;
mod probe_schedule;
mod reply_classification;
mod reply_decode;
mod reply_metadata;
mod timeouts;
mod timing_math;
@@ -0,0 +1,190 @@
use super::*;
#[test]
fn minimal_negotiated_layout_works() {
let params = Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Both,
..Params::default()
};
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
if let ClientEvent::EchoReply {
received_stats,
server_timing,
..
} = &events[0]
{
assert!(received_stats.is_none());
assert!(server_timing.is_none());
} else {
panic!("expected EchoReply");
}
server.join();
}
// ---------- Regression tests ----------
#[test]
fn short_echo_reply_does_not_emit_echo_reply() {
let params = Params {
length: 64,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.truncate(echo_packet_len(false, &params) - 1);
socket.send_to(&reply, peer).unwrap();
}
});
let config = ClientConfig {
length: 64,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn overlong_datagram_detection_uses_extra_receive_byte() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.push(0);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn exact_length_echo_reply_still_emits_echo_reply() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoReply { bytes, .. }] if *bytes == echo_packet_len(false, &params)
));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,121 @@
use super::*;
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now0).is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now1).is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client.send_probe_at(now2).unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
@@ -0,0 +1,113 @@
use super::*;
#[test]
fn duplicate_reply_emits_duplicate_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
thread::sleep(Duration::from_millis(10));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events1 = client.recv_once().unwrap();
assert_eq!(events1.len(), 1);
assert!(matches!(&events1[0], ClientEvent::EchoReply { .. }));
thread::sleep(Duration::from_millis(30));
let events2 = client.recv_once().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(
&events2[0],
ClientEvent::DuplicateReply { seq: 0, .. }
));
server.join();
}
#[test]
fn out_of_order_reply_emits_late_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
seqs.push(seq);
}
}
let ts = TimestampFields::default();
let reply1 = echo_reply_packet(TOKEN, seqs[1], &params, &ts, None);
socket.send_to(&reply1, peer).unwrap();
thread::sleep(Duration::from_millis(10));
let reply0 = echo_reply_packet(TOKEN, seqs[0], &params, &ts, None);
socket.send_to(&reply0, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let ev1 = client.recv_once().unwrap();
assert_eq!(ev1.len(), 1);
assert!(matches!(&ev1[0], ClientEvent::EchoReply { seq: 1, .. }));
thread::sleep(Duration::from_millis(30));
let ev2 = client.recv_once().unwrap();
assert_eq!(ev2.len(), 1);
match &ev2[0] {
ClientEvent::LateReply {
seq,
highest_seen,
rtt,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*highest_seen, 1);
assert!(rtt.is_some());
}
other => panic!("expected LateReply, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,140 @@
use super::*;
#[test]
fn recv_once_returns_empty_on_timeout() {
let params = default_params();
let server = open_success_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.recv_once().unwrap();
assert!(events.is_empty());
server.join();
}
#[test]
fn recv_once_decodes_echo_reply_and_emits_event() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
seq,
rtt,
received_stats,
server_timing,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*bytes, echo_packet_len(false, &params));
assert!(rtt.raw > Duration::ZERO);
assert_eq!(
rtt.effective,
rtt.adjusted
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
);
assert!(received_stats.is_some());
let stats = received_stats.as_ref().unwrap();
assert_eq!(stats.count, Some(42));
assert_eq!(stats.window, Some(0x07));
assert!(server_timing.is_some());
let st = server_timing.as_ref().unwrap();
assert!(st.processing.is_some());
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
{
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
{
assert_eq!(
packet_meta.dscp,
packet_meta.traffic_class.map(|tc| tc >> 2)
);
assert_eq!(
packet_meta.ecn,
packet_meta.traffic_class.map(|tc| tc & 0b11)
);
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
let params = default_params();
let server_params = params.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
socket.send_to(&reply, peer).unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let (packet, _) = recv_request(&socket, &tx);
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
}
let mut longer = echo_reply_packet(
TOKEN,
seqs[0],
&server_params,
&TimestampFields::default(),
None,
);
longer.extend_from_slice(b"trailing bytes");
socket.send_to(&longer, peer).unwrap();
let shorter = echo_reply_packet(
TOKEN,
seqs[1],
&server_params,
&TimestampFields::default(),
None,
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = client.recv_once().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { seq, bytes, .. } => {
assert_eq!(*seq, 1);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,86 @@
#[cfg(all(target_os = "linux", feature = "ancillary"))]
use super::*;
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
crate::socket_options::apply_dscp_to_socket(&socket, peer, 46).unwrap();
let reply_packet =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(traffic_class) = packet_meta.traffic_class else {
metadata_unavailable_skip(
"echo_reply_metadata_propagates_observed_dscp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert_eq!(traffic_class, 184);
assert_eq!(packet_meta.dscp, Some(46));
assert_eq!(packet_meta.ecn, Some(0));
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(timestamp) = packet_meta.kernel_rx_timestamp else {
kernel_rx_timestamp_unavailable_skip(
"echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert!(timestamp.duration_since(SystemTime::UNIX_EPOCH).is_ok());
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
@@ -0,0 +1,161 @@
use super::*;
#[test]
fn poll_timeouts_emits_echo_loss() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(100),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
let no_loss = client.poll_timeouts().unwrap();
assert!(no_loss.is_empty());
thread::sleep(Duration::from_millis(150));
let events = client.poll_timeouts().unwrap();
assert_eq!(events.len(), 2);
for event in &events {
assert!(matches!(event, ClientEvent::EchoLoss { .. }));
}
server.join();
}
#[test]
fn late_reply_after_timeout_preserves_measurement_metadata() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
thread::sleep(Duration::from_millis(90));
let ts = TimestampFields {
recv_wall: Some(1_000_000_000),
recv_mono: Some(100_000),
send_wall: Some(1_000_100_000),
send_mono: Some(200_000),
..Default::default()
};
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
let losses = client.poll_timeouts().unwrap();
assert!(matches!(&losses[0], ClientEvent::EchoLoss { seq: 0, .. }));
let late = client.recv_once().unwrap();
match &late[0] {
ClientEvent::LateReply {
seq,
sent_at,
rtt,
server_timing,
one_way,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert!(sent_at.is_some());
assert!(rtt.is_some());
assert!(server_timing.is_some());
assert!(one_way.is_some());
assert_eq!(*bytes, echo_packet_len(false, &default_params()));
let _ = packet_meta;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
assert_packet_meta_unavailable(packet_meta);
}
other => panic!("expected stats-eligible LateReply, got {other:?}"),
}
let duplicate = client.recv_once().unwrap();
assert!(matches!(
&duplicate[0],
ClientEvent::DuplicateReply {
seq: 0,
bytes,
..
} if *bytes == echo_packet_len(false, &default_params())
));
client.close().unwrap();
server.join();
}
#[test]
fn pending_full_does_not_send_packet() {
let params = Params {
duration_ns: 60_000_000_000,
..default_params()
};
let server = silent_open_server(params);
let config = ClientConfig {
duration: Some(Duration::from_secs(60)),
max_pending_probes: 2,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let before_count: Vec<_> = server.rx.try_iter().collect();
let echo_before: Vec<_> = before_count
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(echo_before.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
thread::sleep(Duration::from_millis(30));
let after: Vec<_> = server.rx.try_iter().collect();
let echo_after: Vec<_> = after
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(
echo_after.len(),
0,
"no packet should be sent when pending is full"
);
client.close().unwrap();
server.join();
}
@@ -0,0 +1,157 @@
use super::*;
#[test]
fn server_processing_greater_than_raw_does_not_underflow() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(1),
wall: SystemTime::now(),
},
&TimestampFields {
recv_mono: Some(0),
send_mono: Some(1_000_000_000),
..Default::default()
},
);
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
}
#[test]
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(42),
wall: SystemTime::now(),
},
&TimestampFields::default(),
);
assert_eq!(rtt.adjusted, None);
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
}
#[test]
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
let overflow = max + Duration::from_nanos(1);
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
assert_eq!(unix_epoch_ns_i64(overflow), None);
}
#[test]
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let overflow_wall =
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: overflow_wall,
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: overflow_wall + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(i64::MAX),
send_wall: Some(i64::MAX),
..Default::default()
};
assert_eq!(compute_one_way(&sent_at, &received_at, &ts), None);
}
#[test]
fn compute_one_way_returns_available_direction_samples() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_client_to_server_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 - 5_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(-5_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_server_to_client_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 45_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(-5_000_000))
);
}
@@ -1,10 +1,4 @@
use super::*; use super::*;
use std::time::Instant;
use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
session::ActiveSession,
};
#[test] #[test]
fn sequence_ordering_handles_normal_and_wrapped_values() { fn sequence_ordering_handles_normal_and_wrapped_values() {
@@ -24,21 +18,10 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
#[test] #[test]
fn highest_received_seq_updates_across_wrap() { fn highest_received_seq_updates_across_wrap() {
let mut session = ActiveSession { let mut highest = Some(u32::MAX);
next_wire_seq: 0,
highest_received_seq: Some(u32::MAX),
packets_sent: 0,
start_mono: Instant::now(),
end_mono: None,
next_send_at: Instant::now(),
pending: PendingMap::new(8),
timed_out: TimedOutMap::new(8),
completed: CompletedSet::new(8),
sending_done: false,
};
update_highest_received(&mut session, 0); update_highest_received(&mut highest, 0);
assert_eq!(session.highest_received_seq, Some(0)); assert_eq!(highest, Some(0));
update_highest_received(&mut session, u32::MAX); update_highest_received(&mut highest, u32::MAX);
assert_eq!(session.highest_received_seq, Some(0)); assert_eq!(highest, Some(0));
} }
+116 -1
View File
@@ -1,12 +1,31 @@
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
/// bytes.
///
/// This is the maximum encoded server-fill string accepted by this client and
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
/// before opening a session.
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt}; use irtt_proto::{Clock, ReceivedStats, StampAt};
pub(crate) const DEFAULT_PORT: u16 = 2112; pub(crate) const DEFAULT_PORT: u16 = 2112;
/// Largest valid DSCP codepoint accepted by client configuration.
///
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
/// socket traffic-class field when configuring packet priority.
pub const MAX_DSCP_CODEPOINT: u8 = 63; pub const MAX_DSCP_CODEPOINT: u8 = 63;
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
///
/// This is the public user-configuration bound for
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
/// TTL and hop-limit settings are configured as `1..=255`.
pub const MAX_TTL: u32 = 255; pub const MAX_TTL: u32 = 255;
/// Largest UDP payload length accepted by client configuration, in bytes.
///
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507; pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
pub const MAX_SERVER_FILL_BYTES: usize = 32;
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3); pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1); pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [ pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
@@ -19,23 +38,83 @@ pub(crate) const MIN_OPEN_TIMEOUT: Duration = Duration::from_millis(200);
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4); pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096; pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// Configuration for opening and running an IRTT client session.
///
/// This type describes both the protocol parameters sent in the IRTT open
/// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig { pub struct ClientConfig {
/// Remote server name or address.
///
/// If no port is present, the default IRTT port 2112 is used. IPv6
/// literals may be supplied either bracketed or unbracketed when the
/// default port should be used.
pub server_addr: String, pub server_addr: String,
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
pub duration: Option<Duration>, pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
pub interval: Duration, pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32, pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats, pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt, pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
pub clock: Clock, pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8, pub dscp: u8,
/// Optional HMAC key used to authenticate IRTT packets.
///
/// When present, open, echo, and close packets are encoded and decoded with
/// HMAC authentication. The peer must be configured with the same key.
pub hmac_key: Option<Vec<u8>>, pub hmac_key: Option<Vec<u8>>,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>, pub server_fill: Option<String>,
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub open_timeouts: Vec<Duration>, pub open_timeouts: Vec<Duration>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode, pub run_mode: RunMode,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation_policy: NegotiationPolicy, pub negotiation_policy: NegotiationPolicy,
/// Local UDP socket configuration.
pub socket_config: SocketConfig, pub socket_config: SocketConfig,
/// Time after sending a probe before the client reports it as lost.
///
/// This timeout is local client behavior; it is not negotiated with the
/// server. It must be greater than zero.
pub probe_timeout: Duration, pub probe_timeout: Duration,
/// Maximum number of probes tracked as pending/timed-out/completed.
///
/// This bounds memory used for reply classification and must be greater
/// than zero. A very small value can reject sends when replies are still
/// outstanding.
pub max_pending_probes: usize, pub max_pending_probes: usize,
} }
@@ -62,29 +141,65 @@ impl Default for ClientConfig {
} }
} }
/// Local UDP socket options used by [`ClientConfig`].
///
/// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the
/// server.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig { pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
///
/// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family.
pub bind_addr: Option<SocketAddr>, pub bind_addr: Option<SocketAddr>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
///
/// `None` leaves the platform default unchanged. Values must fit in the
/// platform socket option range; [`MAX_TTL`] is the public configuration
/// bound used by this crate.
pub ttl: Option<u32>, pub ttl: Option<u32>,
/// Restrict name resolution to IPv4 addresses.
pub ipv4_only: bool, pub ipv4_only: bool,
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
/// sockets.
pub ipv6_only: bool, pub ipv6_only: bool,
/// Socket read timeout used after the session is open.
///
/// `None` leaves reads blocking for APIs that perform a single receive.
/// Managed sessions may replace `None` or long timeouts with a short
/// timeout so cooperative cancellation can be observed promptly.
pub recv_timeout: Option<Duration>, pub recv_timeout: Option<Duration>,
} }
/// How strictly to handle server-side negotiation restrictions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NegotiationPolicy { pub enum NegotiationPolicy {
/// Reject any negotiated parameter that is more restrictive or different
/// than requested.
Strict, Strict,
/// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams).
Loose, Loose,
} }
/// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode { pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration.
Normal, Normal,
/// Complete negotiation without running the echo probe test.
NoTest, NoTest,
} }
/// Bound for a single receive-drain operation.
///
/// This is used by lower-level callers that drive [`Client`](crate::Client)
/// directly and want to cap how many datagrams are processed before returning
/// to their own event loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecvBudget { pub struct RecvBudget {
/// Maximum number of datagrams to process before returning.
pub max_packets: usize, pub max_packets: usize,
} }
+110
View File
@@ -2,66 +2,176 @@ use std::{io, time::Duration};
use thiserror::Error; use thiserror::Error;
/// Failure reported while configuring, opening, running, receiving, closing, or
/// joining an IRTT client session.
///
/// Configuration validation errors usually require changing
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
/// Network, protocol, and runtime errors may be transient, but can also reflect
/// peer incompatibility or an invalid session state.
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum ClientError { pub enum ClientError {
/// The configured server address could not be resolved to a socket address.
///
/// Check the host, port, address-family restrictions, and name service
/// configuration before retrying.
#[error("failed to resolve server address {addr:?}")] #[error("failed to resolve server address {addr:?}")]
Resolve { addr: String }, Resolve { addr: String },
/// A UDP socket operation failed while binding, connecting, sending, or
/// receiving.
///
/// This can be a transient network or OS error, but may also reflect local
/// permissions, routing, or socket configuration.
#[error("socket error: {0}")] #[error("socket error: {0}")]
Socket(#[from] io::Error), Socket(#[from] io::Error),
/// Applying or reading a socket option failed.
///
/// This is usually caused by an unsupported option, an invalid local value,
/// or a platform-specific socket limitation. Changing socket configuration
/// is more likely to help than retrying unchanged.
#[error("socket option error while trying to {operation} for {remote}: {source}")] #[error("socket option error while trying to {operation} for {remote}: {source}")]
SocketOption { SocketOption {
operation: &'static str, operation: &'static str,
remote: std::net::SocketAddr, remote: std::net::SocketAddr,
source: io::Error, source: io::Error,
}, },
/// Restoring the configured read timeout after open negotiation failed.
///
/// The session may have opened successfully, but the socket could not be
/// returned to the caller's requested receive-timeout behavior.
#[error("failed to restore configured socket read timeout after open negotiation: {source}")] #[error("failed to restore configured socket read timeout after open negotiation: {source}")]
ReadTimeoutRestore { source: io::Error }, ReadTimeoutRestore { source: io::Error },
/// A packet failed IRTT protocol parsing or authentication.
///
/// Likely causes include malformed input, unsupported protocol data, or an
/// HMAC mismatch between client and server.
#[error("protocol error: {0}")] #[error("protocol error: {0}")]
Protocol(#[from] irtt_proto::ProtoError), Protocol(#[from] irtt_proto::ProtoError),
/// No valid open reply was received before all configured open attempts
/// timed out.
///
/// Retrying can succeed when the server or network is temporarily
/// unavailable. Persistent failures usually require changing the address,
/// authentication key, server configuration, or timeout settings.
#[error("all open requests timed out")] #[error("all open requests timed out")]
OpenTimeout, OpenTimeout,
/// An open-attempt timeout is below the client's supported minimum.
///
/// Increase the configured timeout before retrying.
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")] #[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
OpenTimeoutTooSmall { OpenTimeoutTooSmall {
timeout: Duration, timeout: Duration,
minimum: Duration, minimum: Duration,
}, },
/// The configured open-attempt timeout list is empty.
///
/// Provide at least one timeout before opening the session.
#[error("open_timeouts must not be empty")] #[error("open_timeouts must not be empty")]
NoOpenTimeouts, NoOpenTimeouts,
/// The server explicitly rejected the open request.
///
/// Retrying unchanged is unlikely to help unless the server configuration or
/// load has changed.
#[error("server rejected the open request")] #[error("server rejected the open request")]
ServerRejected, ServerRejected,
/// A no-test open request received a reply that did not have no-test close
/// semantics.
///
/// This indicates an incompatible or unexpected peer response.
#[error("unexpected no-test open reply")] #[error("unexpected no-test open reply")]
UnexpectedNoTestReply, UnexpectedNoTestReply,
/// A no-test open reply included a connection token.
///
/// No-test negotiation must not establish a probe session, so a non-zero
/// token indicates an incompatible or unexpected peer response.
#[error("no-test reply included a non-zero connection token: {token}")] #[error("no-test reply included a non-zero connection token: {token}")]
NonZeroNoTestToken { token: u64 }, NonZeroNoTestToken { token: u64 },
/// The peer replied with a protocol version different from the requested
/// version.
///
/// This is a protocol compatibility error and requires compatible client
/// and server versions.
#[error("protocol version mismatch: requested {requested}, received {received}")] #[error("protocol version mismatch: requested {requested}, received {received}")]
ProtocolVersionMismatch { requested: i64, received: i64 }, ProtocolVersionMismatch { requested: i64, received: i64 },
/// The server returned a zero connection token for a normal test session.
///
/// A normal session requires a non-zero token to correlate echo traffic, so
/// this indicates an invalid peer response.
#[error("server returned a zero connection token")] #[error("server returned a zero connection token")]
ZeroToken, ZeroToken,
/// Negotiated parameters were rejected by the configured negotiation policy.
///
/// Use a less restrictive configuration or
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
/// caller accepts documented server restrictions.
#[error("strict negotiation rejected changed params: {reason}")] #[error("strict negotiation rejected changed params: {reason}")]
NegotiationRejected { reason: String }, NegotiationRejected { reason: String },
/// The requested operation requires an open session, but the client is only
/// connected.
#[error("client is not open")] #[error("client is not open")]
NotOpen, NotOpen,
/// The session is already open.
///
/// Continue running, receiving, or closing the existing session instead of
/// opening it again.
#[error("client session is already open")] #[error("client session is already open")]
AlreadyOpen, AlreadyOpen,
/// The no-test session has already completed.
///
/// Create a new client session to perform another negotiation.
#[error("client session is already completed")] #[error("client session is already completed")]
AlreadyCompleted, AlreadyCompleted,
/// The session is already closed.
///
/// Create a new client session before opening or running another test.
#[error("client session is already closed")] #[error("client session is already closed")]
AlreadyClosed, AlreadyClosed,
/// A duration could not be represented as nanoseconds for scheduling or
/// protocol encoding.
///
/// Use smaller configured or negotiated durations.
#[error("duration is too large to encode as nanoseconds")] #[error("duration is too large to encode as nanoseconds")]
DurationOverflow, DurationOverflow,
/// A client-maintained counter would overflow.
///
/// This is a runtime limit rather than a network failure. Start a new
/// session if the test duration or send volume reaches this limit.
#[error("counter {counter} overflowed")] #[error("counter {counter} overflowed")]
CounterOverflow { counter: &'static str }, CounterOverflow { counter: &'static str },
/// Sending another probe would exceed the configured pending-probe limit.
///
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
/// reduce send rate, or receive replies and timeouts more often before
/// sending again.
#[error("pending probe limit exceeded ({limit})")] #[error("pending probe limit exceeded ({limit})")]
PendingLimitExceeded { limit: usize }, PendingLimitExceeded { limit: usize },
/// Client configuration failed validation before the requested operation.
///
/// The reported reason names a value that must be changed before retrying.
#[error("invalid configuration: {reason}")] #[error("invalid configuration: {reason}")]
InvalidConfig { reason: String }, InvalidConfig { reason: String },
/// A managed-session worker thread panicked before producing an outcome.
///
/// This is a runtime failure from joining the managed session, not a
/// protocol response from the server.
#[error("managed client worker thread panicked")] #[error("managed client worker thread panicked")]
WorkerPanicked, WorkerPanicked,
} }
/// Failure reported while setting up or receiving from an event subscription.
///
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
/// Once a subscription exists, this error type reports delivery failures observed
/// by [`EventSubscription`](crate::EventSubscription).
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EventSubscriptionError { pub enum EventSubscriptionError {
/// The subscription can no longer receive new events.
///
/// Already queued events are delivered first. After the queue is drained,
/// `recv` and `try_recv` return this error when the hub disconnects the
/// subscriber, the hub is shut down, or the subscription is disconnected by
/// its overflow policy.
#[error("event subscription is disconnected")] #[error("event subscription is disconnected")]
Disconnected, Disconnected,
} }
+229 -23
View File
@@ -5,139 +5,360 @@ use std::{
use crate::{session::NegotiatedParams, timing::ClientTimestamp}; use crate::{session::NegotiatedParams, timing::ClientTimestamp};
/// Result of the IRTT open exchange.
///
/// The lower-level [`Client`](crate::Client) API returns this before any echo
/// probes are driven. Managed sessions publish the contained lifecycle event to
/// subscribers immediately after a successful open.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenOutcome { pub enum OpenOutcome {
/// A normal probe session was opened.
Started { Started {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr, remote: SocketAddr,
/// Session token assigned by the server and used on echo/close packets.
token: u64, token: u64,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams, negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent, event: ClientEvent,
}, },
/// A no-test open exchange completed without starting a probe session.
NoTestCompleted { NoTestCompleted {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr, remote: SocketAddr,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams, negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent, event: ClientEvent,
}, },
} }
/// Event emitted by an IRTT client session.
///
/// Events form the public lifecycle and measurement stream for callers. UIs
/// usually display lifecycle, warning, and per-packet events directly; summary
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
/// `LateReply`, and `DuplicateReply` events or pass the stream to
/// `irtt-stats`.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent { pub enum ClientEvent {
/// A normal session has opened and is ready to send echo probes.
SessionStarted { SessionStarted {
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Session token assigned by the server.
token: u64, token: u64,
/// Negotiated protocol parameters for this session.
negotiated: NegotiatedParams, negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp, at: ClientTimestamp,
}, },
/// A negotiation-only no-test exchange completed.
///
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
/// expected for this open outcome.
NoTestCompleted { NoTestCompleted {
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Negotiated protocol parameters returned by the server.
negotiated: NegotiatedParams, negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp, at: ClientTimestamp,
}, },
/// The client sent a close request and considers the session closed.
///
/// This is a local lifecycle event, not an acknowledgement from the server.
/// Managed sessions disconnect event subscribers after the worker exits,
/// while leaving already queued events available to drain.
SessionClosed { SessionClosed {
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Session token that was closed.
token: u64, token: u64,
/// Client timestamp when the close request was sent.
at: ClientTimestamp, at: ClientTimestamp,
}, },
/// An echo request was sent.
///
/// This records local scheduling and send-call timing. It does not imply a
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
/// packet lifecycle state.
EchoSent { EchoSent {
/// Wire sequence number sent in the echo request.
seq: u32, seq: u32,
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent.
scheduled_at: Instant, scheduled_at: Instant,
/// Client wall/monotonic timestamp captured for the send.
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Number of bytes passed to the UDP socket.
bytes: usize, bytes: usize,
/// Elapsed time spent in the socket send call.
send_call: Duration, send_call: Duration,
/// Absolute difference between the scheduled send time and actual send
/// timestamp.
timer_error: Duration, timer_error: Duration,
}, },
/// First in-window reply for a pending echo request.
///
/// This is the primary successful measurement event. RTT and optional
/// server timing/statistics fields should generally be aggregated from this
/// variant.
EchoReply { EchoReply {
/// Wire sequence number from the echo reply.
seq: u32, seq: u32,
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Client timestamp recorded when the matching request was sent.
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Client timestamp recorded when the reply was received.
received_at: ClientTimestamp, received_at: ClientTimestamp,
/// Round-trip timing sample for this reply.
rtt: RttSample, rtt: RttSample,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>, server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>, one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>, received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize, bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta, packet_meta: PacketMeta,
}, },
/// A sent echo request exceeded the local probe timeout before a reply was
/// matched.
///
/// A later datagram for the same sequence may still arrive and will be
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
EchoLoss { EchoLoss {
/// Wire sequence number that timed out.
seq: u32, seq: u32,
/// Client timestamp recorded when the request was sent.
sent_at: ClientTimestamp, sent_at: ClientTimestamp,
/// Monotonic deadline at which the probe was declared lost.
timeout_at: std::time::Instant, timeout_at: std::time::Instant,
}, },
/// Additional reply for a sequence that was already completed.
///
/// Duplicates are not primary RTT samples. They are useful for diagnostics
/// and packet counters, but should not be aggregated as successful first
/// replies.
DuplicateReply { DuplicateReply {
/// Wire sequence number from the duplicate reply.
seq: u32, seq: u32,
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Client timestamp recorded when the duplicate was received.
received_at: ClientTimestamp, received_at: ClientTimestamp,
/// UDP datagram size received from the socket.
bytes: usize, bytes: usize,
}, },
/// Reply for a sequence that was no longer pending.
///
/// This commonly means the probe had already been reported as lost, or the
/// reply arrived out of the client's current tracking window. When the
/// original send metadata is still retained, RTT and one-way timing are
/// included; otherwise those fields are `None`.
LateReply { LateReply {
/// Wire sequence number from the late reply.
seq: u32, seq: u32,
/// Highest sequence number accepted as an in-window first reply so far.
highest_seen: u32, highest_seen: u32,
/// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Original client send timestamp, when still retained.
sent_at: Option<ClientTimestamp>, sent_at: Option<ClientTimestamp>,
/// Client timestamp recorded when the late reply was received.
received_at: ClientTimestamp, received_at: ClientTimestamp,
/// RTT sample computed when the original send timestamp is available.
rtt: Option<RttSample>, rtt: Option<RttSample>,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>, server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>, one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>, received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize, bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta, packet_meta: PacketMeta,
}, },
/// Non-fatal condition observed while receiving or classifying packets.
///
/// Warning events are diagnostic. They do not close the session by
/// themselves and are not measurement samples.
Warning { Warning {
/// Machine-readable warning category.
kind: WarningKind, kind: WarningKind,
/// Human-readable diagnostic text.
message: String, message: String,
/// Client timestamp when the warning was emitted.
at: ClientTimestamp, at: ClientTimestamp,
}, },
} }
/// Category for a non-fatal [`ClientEvent::Warning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive] #[non_exhaustive]
pub enum WarningKind { pub enum WarningKind {
/// A received datagram could not be decoded as a relevant IRTT packet.
MalformedOrUnrelatedPacket, MalformedOrUnrelatedPacket,
/// A decoded packet used a token that does not match the open session.
WrongToken, WrongToken,
/// A decoded reply could not be matched to tracked probe state.
UntrackedReply, UntrackedReply,
} }
/// Round-trip timing for one echo reply.
///
/// `raw` is the client-observed elapsed time from sending the echo request to
/// receiving the reply and is therefore non-negative. When the server reports
/// enough monotonic timing to compute processing time, `adjusted` subtracts
/// that server processing from `raw`. `effective` is the value used by reporting
/// and statistics: adjusted when available, otherwise raw converted to a signed
/// value.
///
/// Adjusted and effective RTT values are signed. They may be negative if the
/// reported server processing time is greater than the raw client-observed RTT.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RttSample { pub struct RttSample {
/// Client-observed RTT from send to receive.
pub raw: Duration, pub raw: Duration,
pub adjusted: Option<Duration>, /// Signed RTT adjusted for server processing when server processing is available.
pub effective: Duration, pub adjusted: Option<SignedDuration>,
pub adjusted_signed: Option<SignedDuration>, /// Signed effective RTT used by stats and CLI reporting.
pub effective_signed: SignedDuration, ///
/// This is `adjusted` when server processing is available, otherwise `raw`
/// converted to a signed duration.
pub effective: SignedDuration,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// Signed nanosecond-backed duration used for derived timing values.
///
/// This type preserves negative values that can arise after subtracting server
/// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration { pub struct SignedDuration {
pub ns: i128, ns: i128,
} }
impl SignedDuration {
/// Construct a signed duration from nanoseconds.
pub const fn from_nanos(ns: i128) -> Self {
Self { ns }
}
/// Convert a non-negative [`Duration`] into a signed duration.
///
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
pub fn from_duration(duration: Duration) -> Self {
Self {
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
}
}
/// Return the duration in nanoseconds.
pub const fn as_nanos(self) -> i128 {
self.ns
}
/// Return the duration in whole microseconds, truncated toward zero.
pub const fn as_micros(self) -> i128 {
self.ns / 1_000
}
/// Return the duration in whole milliseconds, truncated toward zero.
pub const fn as_millis(self) -> i128 {
self.ns / 1_000_000
}
/// Return whether this duration is less than zero.
pub const fn is_negative(self) -> bool {
self.ns < 0
}
}
impl From<Duration> for SignedDuration {
fn from(value: Duration) -> Self {
Self::from_duration(value)
}
}
/// Server-reported timing fields decoded from an echo reply.
///
/// These values are optional because the negotiated timestamp mode, clock mode,
/// server implementation, or packet contents may omit individual fields.
/// Timestamp values are nanoseconds in the server's reported wall-clock or
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
/// reported a zero timestamp.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerTiming { pub struct ServerTiming {
/// Server wall-clock receive timestamp, in nanoseconds.
pub receive_wall_ns: Option<i64>, pub receive_wall_ns: Option<i64>,
/// Server monotonic receive timestamp, in nanoseconds.
pub receive_mono_ns: Option<i64>, pub receive_mono_ns: Option<i64>,
/// Server wall-clock send timestamp, in nanoseconds.
pub send_wall_ns: Option<i64>, pub send_wall_ns: Option<i64>,
/// Server monotonic send timestamp, in nanoseconds.
pub send_mono_ns: Option<i64>, pub send_mono_ns: Option<i64>,
/// Server wall-clock midpoint timestamp, in nanoseconds.
pub midpoint_wall_ns: Option<i64>, pub midpoint_wall_ns: Option<i64>,
/// Server monotonic midpoint timestamp, in nanoseconds.
pub midpoint_mono_ns: Option<i64>, pub midpoint_mono_ns: Option<i64>,
/// Server processing time computed from monotonic receive/send timestamps.
///
/// `None` means the required monotonic timestamps were unavailable or
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
/// was available and zero.
pub processing: Option<Duration>, pub processing: Option<Duration>,
} }
/// Directional one-way delay samples computed from wall-clock timestamps.
///
/// Each directional field is an `Option<SignedDuration>`. `None` means the
/// required client or server wall-clock timestamp was unavailable, or the
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
/// computed from available timestamps.
///
/// Negative computed values are preserved as `Some` instead of being clamped to
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
/// between the client and server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OneWayDelaySample { pub struct OneWayDelaySample {
pub client_to_server: Option<Duration>, /// Client-to-server delay.
pub server_to_client: Option<Duration>, ///
/// This is computed from the client send wall time and the server receive
/// wall time when both are available.
pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay.
///
/// This is computed from the server send wall time and the client receive
/// wall time when both are available.
pub server_to_client: Option<SignedDuration>,
} }
/// Server-reported receive counters decoded from an echo reply.
///
/// These fields are optional because the negotiated received-statistics mode or
/// server implementation may omit them. `None` means unavailable; `Some(0)`
/// means the server reported the value and it was zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceivedStatsSample { pub struct ReceivedStatsSample {
/// Server-reported count of received packets, when available.
pub count: Option<u32>, pub count: Option<u32>,
/// Server-reported receive window value, when available.
pub window: Option<u64>, pub window: Option<u64>,
} }
@@ -152,18 +373,3 @@ pub struct PacketMeta {
pub ecn: Option<u8>, pub ecn: Option<u8>,
pub kernel_rx_timestamp: Option<std::time::SystemTime>, pub kernel_rx_timestamp: Option<std::time::SystemTime>,
} }
#[cfg(test)]
mod tests {
use super::PacketMeta;
#[test]
fn packet_meta_default_is_unavailable() {
let meta = PacketMeta::default();
assert_eq!(meta.traffic_class, None);
assert_eq!(meta.dscp, None);
assert_eq!(meta.ecn, None);
assert_eq!(meta.kernel_rx_timestamp, None);
}
}
+47
View File
@@ -1,3 +1,50 @@
//! Reusable client/session/event layer for IRTT-compatible probes.
//!
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
//! values for callers to consume directly or aggregate with `irtt-stats`.
//!
//! Timing values intentionally preserve signed measurement semantics. When
//! server timing is available, [`RttSample::effective`] is adjusted for server
//! processing and can be negative if the reported server processing time exceeds
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
//! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew.
//!
//! A managed session can drive the socket loop on a worker thread and publish
//! events through a subscription:
//!
//! ```no_run
//! use std::time::Duration;
//!
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = ClientConfig {
//! server_addr: "127.0.0.1:2112".to_owned(),
//! duration: Some(Duration::from_secs(10)),
//! interval: Duration::from_secs(1),
//! ..ClientConfig::default()
//! };
//!
//! let (session, events) =
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
//!
//! while let Ok(event) = events.recv() {
//! match event {
//! ClientEvent::EchoReply { seq, rtt, .. } => {
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
//! }
//! ClientEvent::SessionClosed { .. } => break,
//! _ => {}
//! }
//! }
//!
//! let _outcome = session.join()?;
//! # Ok(())
//! # }
//! ```
//!
#![cfg_attr( #![cfg_attr(
not(all(target_os = "linux", feature = "ancillary")), not(all(target_os = "linux", feature = "ancillary")),
forbid(unsafe_code) forbid(unsafe_code)
+11 -5
View File
@@ -3,20 +3,31 @@ use std::sync::{
Arc, Arc,
}; };
/// Cloneable cooperative cancellation flag.
///
/// Managed sessions use this internally to ask their worker loop to stop. It is
/// exported for callers that want the same simple cancellation primitive in
/// code built around `irtt-client`.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct CancellationToken { pub struct CancellationToken {
cancelled: Arc<AtomicBool>, cancelled: Arc<AtomicBool>,
} }
impl CancellationToken { impl CancellationToken {
/// Create a token in the non-cancelled state.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Mark the token as cancelled.
///
/// Cancellation is idempotent and is visible through all clones of the
/// token.
pub fn cancel(&self) { pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst); self.cancelled.store(true, Ordering::SeqCst);
} }
/// Return whether cancellation has been requested.
#[must_use] #[must_use]
pub fn is_cancelled(&self) -> bool { pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst) self.cancelled.load(Ordering::SeqCst)
@@ -27,11 +38,6 @@ impl CancellationToken {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn token_starts_uncancelled() {
assert!(!CancellationToken::new().is_cancelled());
}
#[test] #[test]
fn cancel_is_idempotent_and_visible_through_clones() { fn cancel_is_idempotent_and_visible_through_clones() {
let token = CancellationToken::new(); let token = CancellationToken::new();
+65 -55
View File
@@ -8,9 +8,17 @@ use std::{
use crate::{error::EventSubscriptionError, event::ClientEvent}; use crate::{error::EventSubscriptionError, event::ClientEvent};
/// Configuration for one managed event subscriber.
///
/// Each subscriber has an independent bounded queue. The queue bounds memory
/// growth when event production is faster than that subscriber's consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubscriberConfig { pub struct SubscriberConfig {
/// Maximum number of events buffered for this subscriber.
///
/// Must be greater than zero.
pub capacity: usize, pub capacity: usize,
/// Policy applied when publishing to a full subscriber queue.
pub overflow: SubscriberOverflow, pub overflow: SubscriberOverflow,
} }
@@ -37,6 +45,11 @@ pub enum SubscriberOverflow {
Disconnect, Disconnect,
} }
/// Publish/subscribe fan-out for [`ClientEvent`] values.
///
/// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EventHub { pub struct EventHub {
inner: Arc<HubInner>, inner: Arc<HubInner>,
@@ -75,6 +88,7 @@ struct SubscriberState {
} }
impl EventHub { impl EventHub {
/// Create an empty event hub with no subscribers.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
inner: Arc::new(HubInner { inner: Arc::new(HubInner {
@@ -84,6 +98,9 @@ impl EventHub {
} }
} }
/// Register a new subscriber with its own bounded queue.
///
/// Returns an error when `config.capacity` is zero.
pub fn subscribe( pub fn subscribe(
&self, &self,
config: SubscriberConfig, config: SubscriberConfig,
@@ -116,6 +133,11 @@ impl EventHub {
}) })
} }
/// Publish an event to all currently connected subscribers.
///
/// Slow subscribers are handled according to their
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
/// subscriber to consume queued events.
pub fn publish(&self, event: ClientEvent) { pub fn publish(&self, event: ClientEvent) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
.inner .inner
@@ -149,6 +171,8 @@ impl EventHub {
} }
} }
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) { pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self let subscribers: Vec<Arc<SubscriberInner>> = self
.inner .inner
@@ -290,14 +314,16 @@ mod tests {
} }
} }
#[test] fn assert_next(
fn publishes_to_one_subscriber() { sub: &EventSubscription,
let hub = EventHub::new(); expected: Result<Option<&str>, EventSubscriptionError>,
let sub = hub.subscribe(SubscriberConfig::default()).unwrap(); ) {
match (sub.try_recv(), expected) {
hub.publish(event(1)); (Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
(Ok(None), Ok(None)) => {}
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1"); (Err(actual), Err(expected)) => assert_eq!(actual, expected),
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
} }
#[test] #[test]
@@ -313,56 +339,40 @@ mod tests {
} }
#[test] #[test]
fn drop_newest_keeps_existing_events() { fn full_subscriber_overflow_policy_is_applied() {
let hub = EventHub::new(); let cases = [
let sub = hub (
.subscribe(SubscriberConfig { SubscriberOverflow::DropNewest,
capacity: 1, Ok(Some("event-1")),
overflow: SubscriberOverflow::DropNewest, Ok(None),
}) ),
.unwrap(); (
SubscriberOverflow::DropOldest,
Ok(Some("event-2")),
Ok(None),
),
(
SubscriberOverflow::Disconnect,
Err(EventSubscriptionError::Disconnected),
Err(EventSubscriptionError::Disconnected),
),
];
hub.publish(event(1)); for (overflow, first_expected, second_expected) in cases {
hub.publish(event(2)); let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow,
})
.unwrap();
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1"); hub.publish(event(1));
assert!(sub.try_recv().unwrap().is_none()); hub.publish(event(2));
}
#[test] assert_next(&sub, first_expected);
fn drop_oldest_replaces_existing_events() { assert_next(&sub, second_expected);
let hub = EventHub::new(); }
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropOldest,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
assert!(sub.try_recv().unwrap().is_none());
}
#[test]
fn disconnect_removes_full_subscriber() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::Disconnect,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(
sub.try_recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
} }
#[test] #[test]
+43
View File
@@ -21,6 +21,12 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
const IDLE_SLEEP: Duration = Duration::from_millis(1); const IDLE_SLEEP: Duration = Duration::from_millis(1);
const MAX_SLEEP: Duration = Duration::from_millis(20); const MAX_SLEEP: Duration = Duration::from_millis(20);
/// Entry point for running a client session on a worker thread.
///
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
/// opens the session, sends probes at the negotiated interval, receives
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
/// run completes or is cancelled.
#[derive(Debug)] #[derive(Debug)]
pub struct ManagedClient; pub struct ManagedClient;
@@ -46,26 +52,49 @@ pub struct ManagedClientSession {
#[must_use = "managed session outcomes contain completion status and counters"] #[must_use = "managed session outcomes contain completion status and counters"]
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionOutcome { pub struct SessionOutcome {
/// Why the managed loop stopped.
pub end_reason: SessionEndReason, pub end_reason: SessionEndReason,
/// Number of echo requests sent by the client.
pub packets_sent: u64, pub packets_sent: u64,
/// Number of first in-window echo replies received.
pub replies_received: u64, pub replies_received: u64,
/// Number of duplicate reply events emitted.
pub duplicates: u64, pub duplicates: u64,
/// Number of late reply events emitted.
pub late: u64, pub late: u64,
/// Number of warning events emitted.
pub warning_events: u64, pub warning_events: u64,
} }
/// Reason a managed client session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionEndReason { pub enum SessionEndReason {
/// The negotiated finite test duration completed and the client closed the
/// session normally.
TestComplete, TestComplete,
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
/// dropping the session handle.
Cancelled, Cancelled,
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
/// completed after negotiation.
NoTestComplete, NoTestComplete,
} }
impl ManagedClient { impl ManagedClient {
/// Start a managed session without creating an initial event subscription.
///
/// The returned [`ManagedClientSession`] can still create subscriptions via
/// [`ManagedClientSession::subscribe`], but events emitted before a
/// subscriber is registered are not replayed.
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> { pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
Self::start_inner(config, None).map(|(session, _)| session) Self::start_inner(config, None).map(|(session, _)| session)
} }
/// Start a managed session and subscribe to events before the worker runs.
///
/// The initial subscription receives the open lifecycle event and
/// subsequent session events, subject to its queue capacity and overflow
/// policy.
pub fn start_with_subscription( pub fn start_with_subscription(
config: ClientConfig, config: ClientConfig,
subscriber_config: SubscriberConfig, subscriber_config: SubscriberConfig,
@@ -114,14 +143,28 @@ impl ManagedClient {
} }
impl ManagedClientSession { impl ManagedClientSession {
/// Add another event subscriber to this running managed session.
///
/// The subscription receives only events published after it is registered.
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> { pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
self.hub.subscribe(config) self.hub.subscribe(config)
} }
/// Request cooperative cancellation of the managed session.
///
/// Cancellation is observed by the worker loop between socket receive,
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
/// wait for the worker and obtain the final [`SessionOutcome`].
pub fn stop(&self) { pub fn stop(&self) {
self.cancellation.cancel(); self.cancellation.cancel();
} }
/// Wait for the managed worker thread to finish.
///
/// On success, returns lifecycle counters for the completed session. If the
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
/// also disconnects all event subscriptions while leaving already queued
/// events available to drain.
pub fn join(mut self) -> Result<SessionOutcome, ClientError> { pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
let worker = self let worker = self
.worker .worker
-11
View File
@@ -24,17 +24,6 @@ mod tests {
use super::ReceiveMeta; use super::ReceiveMeta;
use crate::event::PacketMeta; use crate::event::PacketMeta;
#[test]
fn metadata_default_is_unavailable() {
let meta = ReceiveMeta::default();
let packet_meta = PacketMeta::from(meta);
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[test] #[test]
fn metadata_observed_traffic_class_zero_preserves_observed_zero() { fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
let packet_meta = PacketMeta::from(ReceiveMeta { let packet_meta = PacketMeta::from(ReceiveMeta {
+1 -16
View File
@@ -193,18 +193,6 @@ mod tests {
} }
} }
#[test]
fn pending_map_insert_and_remove() {
let mut map = PendingMap::new(10);
let now = Instant::now();
let probe = pending(0, now + Duration::from_secs(4));
map.insert(probe).unwrap();
assert_eq!(map.len(), 1);
assert!(map.remove(0).is_some());
assert_eq!(map.len(), 0);
assert!(map.remove(0).is_none());
}
#[test] #[test]
fn pending_map_rejects_over_capacity() { fn pending_map_rejects_over_capacity() {
let mut map = PendingMap::new(2); let mut map = PendingMap::new(2);
@@ -220,7 +208,7 @@ mod tests {
} }
#[test] #[test]
fn completed_set_tracks_and_evicts() { fn bounded_probe_tracking_evicts_oldest_entries() {
let mut set = CompletedSet::new(3); let mut set = CompletedSet::new(3);
set.insert(0); set.insert(0);
set.insert(1); set.insert(1);
@@ -232,10 +220,7 @@ mod tests {
assert_eq!(set.set.len(), 3); assert_eq!(set.set.len(), 3);
assert!(!set.contains(0)); assert!(!set.contains(0));
assert!(set.contains(3)); assert!(set.contains(3));
}
#[test]
fn timed_out_map_tracks_and_evicts() {
let mut map = TimedOutMap::new(2); let mut map = TimedOutMap::new(2);
let now = Instant::now(); let now = Instant::now();
map.insert(pending(0, now)); map.insert(pending(0, now));
+8
View File
@@ -8,9 +8,16 @@ use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap}, probe::{CompletedSet, PendingMap, TimedOutMap},
}; };
/// Protocol parameters accepted for a session.
///
/// `params` contains the server-returned values that the client will use for
/// echo packets. `restrictions` records accepted differences from the request
/// when loose negotiation is enabled.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedParams { pub struct NegotiatedParams {
/// Server-returned protocol parameters used for the session.
pub params: Params, pub params: Params,
/// Accepted server restrictions or parameter changes.
pub restrictions: Vec<NegotiationRestriction>, pub restrictions: Vec<NegotiationRestriction>,
} }
@@ -57,6 +64,7 @@ pub enum NegotiationRestriction {
} }
impl NegotiationRestriction { impl NegotiationRestriction {
/// Return a human-readable description of this negotiated restriction.
pub fn message(&self) -> String { pub fn message(&self) -> String {
self.to_string() self.to_string()
} }
+8
View File
@@ -1,12 +1,20 @@
use std::time::{Instant, SystemTime}; use std::time::{Instant, SystemTime};
/// Client-side timestamp captured in both wall-clock and monotonic time.
///
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
/// calculations. Monotonic time is used for elapsed client-side timing such as
/// RTT, scheduling, and timeouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientTimestamp { pub struct ClientTimestamp {
/// System wall-clock time.
pub wall: SystemTime, pub wall: SystemTime,
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
pub mono: Instant, pub mono: Instant,
} }
impl ClientTimestamp { impl ClientTimestamp {
/// Capture the current wall-clock and monotonic timestamps.
pub fn now() -> Self { pub fn now() -> Self {
Self { Self {
wall: SystemTime::now(), wall: SystemTime::now(),
+29 -59
View File
@@ -122,47 +122,27 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
} }
#[test] #[test]
fn missing_client_key_against_hmac_required_server_times_out() { fn hmac_required_server_rejects_missing_or_wrong_client_key() {
let key = b"compat-secret".to_vec(); for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250)); let server_key = b"compat-secret".to_vec();
let mut config = config_for_params(server.addr, &default_params()); let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
config.open_timeouts = vec![Duration::from_millis(200)]; let mut config = config_for_params(server.addr, &default_params());
config.hmac_key = None; config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut client = Client::connect(config).unwrap(); let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout))); assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let observations = server.observations(1); let observations = server.observations(1);
assert!(matches!( assert!(matches!(
observations.as_slice(), observations.as_slice(),
[ServerObservation::RejectedHmac { [ServerObservation::RejectedHmac {
hmac: false, hmac,
bad_hmac: true bad_hmac: true
}] }] if *hmac == expected_hmac_flag
)); ));
server.join(); server.join();
} }
#[test]
fn wrong_client_key_against_hmac_required_server_times_out() {
let server_key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = Some(b"wrong-secret".to_vec());
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: true,
bad_hmac: true
}]
));
server.join();
} }
#[test] #[test]
@@ -311,25 +291,15 @@ fn backend_hmac_correct_key_succeeds() {
} }
#[test] #[test]
fn backend_hmac_wrong_key_fails() { fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
let server_key = b"compat-secret".to_vec(); for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
let peer = BackendPeer::start_hmac_required(server_key); let server_key = b"compat-secret".to_vec();
let mut config = config_for_params(peer.addr(), &default_params()); let peer = BackendPeer::start_hmac_required(server_key);
config.open_timeouts = vec![Duration::from_millis(200)]; let mut config = config_for_params(peer.addr(), &default_params());
config.hmac_key = Some(b"wrong-secret".to_vec()); config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut client = Client::connect(config).unwrap(); let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout))); assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
} }
#[test]
fn backend_hmac_missing_key_fails() {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
} }
+11 -159
View File
@@ -1,17 +1,20 @@
mod support; mod support;
mod protocol_options {
pub mod backend_smoke;
}
use std::time::Duration; use std::time::Duration;
use irtt_client::{ use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample, Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
RttSample, ServerTiming, RttSample, ServerTiming, SignedDuration,
}; };
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields}; use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
use support::{ use support::{
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config, config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun, server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN, RECV_COUNT, RECV_WINDOW, TOKEN,
}; };
use crate::support::test_echo_packet_len; use crate::support::test_echo_packet_len;
@@ -66,7 +69,10 @@ fn timestamp_modes_drive_reply_event_timing_fields() {
assert!(reply.server_timing.is_none()); assert!(reply.server_timing.is_none());
assert!(reply.one_way.is_none()); assert!(reply.one_way.is_none());
assert!(reply.rtt.adjusted.is_none()); assert!(reply.rtt.adjusted.is_none());
assert_eq!(reply.rtt.effective, reply.rtt.raw); assert_eq!(
reply.rtt.effective,
SignedDuration::from_duration(reply.rtt.raw)
);
assert!(reply.rtt.raw > Duration::ZERO); assert!(reply.rtt.raw > Duration::ZERO);
} }
StampAt::Send => { StampAt::Send => {
@@ -346,7 +352,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
assert_eq!(processing, Duration::from_nanos(1)); assert_eq!(processing, Duration::from_nanos(1));
let adjusted = reply.rtt.adjusted.unwrap(); let adjusted = reply.rtt.adjusted.unwrap();
assert_eq!(reply.rtt.effective, adjusted); assert_eq!(reply.rtt.effective, adjusted);
assert!(adjusted < reply.rtt.raw); assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
} }
fn assert_one_way_presence( fn assert_one_way_presence(
@@ -364,46 +370,6 @@ fn assert_one_way_presence(
assert_eq!(sample.server_to_client.is_some(), server_to_client); assert_eq!(sample.server_to_client.is_some(), server_to_client);
} }
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test] #[test]
fn dscp_configured_open_close_smoke() { fn dscp_configured_open_close_smoke() {
let mut params = default_params(); let mut params = default_params();
@@ -416,117 +382,3 @@ fn dscp_configured_open_close_smoke() {
client.close().unwrap(); client.close().unwrap();
server.join(); server.join();
} }
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
@@ -0,0 +1,158 @@
use irtt_client::{Client, ClientEvent};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use crate::support::{config_for_params, default_params, params_for_modes, BackendPeer};
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
+44 -108
View File
@@ -1,6 +1,6 @@
mod support; mod support;
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, NegotiationPolicy}; use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
use irtt_proto::{Params, TimestampFields}; use irtt_proto::{Params, TimestampFields};
use support::{ use support::{
@@ -32,63 +32,34 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
// ─── Config mapping ─── // ─── Config mapping ───
#[test] #[test]
fn server_fill_none_produces_no_fill_in_open_params() { fn server_fill_none_short_and_max_map_into_open_params() {
let params = default_params(); for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
let run = run_one_probe(params, TimestampFields::default()); let mut params = default_params();
assert!(open_observation_params(&run).server_fill.is_none()); params.server_fill = fill.and_then(server_fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
fill
);
}
} }
#[test] #[test]
fn server_fill_short_value_maps_into_open_params() { fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
let mut params = default_params(); for fill in ["", "0123456789abcdef0123456789abcdefx"] {
params.server_fill = server_fill("abc"); let config = ClientConfig {
let run = run_one_probe(params, TimestampFields::default()); server_fill: Some(fill.to_owned()),
assert_eq!( ..ClientConfig::default()
open_observation_params(&run) };
.server_fill assert!(matches!(
.as_ref() Client::connect(config),
.map(|f| f.value.as_str()), Err(ClientError::InvalidConfig { .. })
Some("abc") ));
); }
}
#[test]
fn server_fill_32_byte_value_maps_into_open_params() {
let mut params = default_params();
let fill = "0123456789abcdef0123456789abcdef";
params.server_fill = server_fill(fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some(fill)
);
}
#[test]
fn server_fill_over_32_bytes_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn server_fill_empty_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
} }
// ─── Open negotiation ─── // ─── Open negotiation ───
@@ -102,53 +73,26 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
} }
#[test] #[test]
fn server_fill_strict_rejects_removed_fill() { fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
let mut requested = default_params(); for (requested_fill, returned_fill) in [
requested.server_fill = server_fill("rand"); (Some("rand"), None),
(Some("abc"), Some("xyz")),
(None, Some("unexpected")),
] {
let mut requested = default_params();
requested.server_fill = requested_fill.and_then(server_fill);
let mut returned = requested.clone(); let mut returned = requested.clone();
returned.server_fill = None; returned.server_fill = returned_fill.and_then(server_fill);
let server = start_open_server(returned, None); let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap(); let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!( assert!(matches!(
client.open(), client.open(),
Err(ClientError::NegotiationRejected { .. }) Err(ClientError::NegotiationRejected { .. })
)); ));
server.join(); server.join();
} }
#[test]
fn server_fill_strict_rejects_different_fill_value() {
let mut requested = default_params();
requested.server_fill = server_fill("abc");
let mut returned = requested.clone();
returned.server_fill = server_fill("xyz");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn server_fill_strict_rejects_unexpected_fill_from_server() {
let requested = default_params();
let mut returned = requested.clone();
returned.server_fill = server_fill("unexpected");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
} }
#[test] #[test]
@@ -176,14 +120,6 @@ fn server_fill_loose_allows_server_to_remove_fill() {
// ─── Echo behavior ─── // ─── Echo behavior ───
#[test]
fn server_fill_echo_reply_decoded_with_fill_configured() {
let mut params = default_params();
params.server_fill = server_fill("rand");
let run = run_one_probe(params, TimestampFields::default());
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
}
// server_fill is a negotiation/config parameter only; it does not affect // server_fill is a negotiation/config parameter only; it does not affect
// client-observable reply payload layout. The client sends server_fill in // client-observable reply payload layout. The client sends server_fill in
// the open request and expects the server to use it for reply payload // the open request and expects the server to use it for reply payload
@@ -275,61 +275,3 @@ macro_rules! debug_eprintln {
} }
use debug_eprintln; use debug_eprintln;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_failure_details_include_bind_command_status_and_output() {
let status = Command::new("sh")
.arg("-c")
.arg("exit 7")
.status()
.expect("shell exits");
let failure = StartupFailure {
bind: "127.0.0.1:12345".to_owned(),
command: "\"irtt\" \"server\"".to_owned(),
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
exit_status: Some(status),
error: Some("readiness timed out or server exited before ready".to_owned()),
};
let details = failure.details();
assert!(details.contains("bind: 127.0.0.1:12345"));
assert!(details.contains("command: \"irtt\" \"server\""));
assert!(details.contains("exit status:"));
assert!(details.contains("address already in use"));
}
#[test]
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
let failures = vec![
StartupFailure {
bind: "127.0.0.1:11111".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
output: "stdout: first".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
StartupFailure {
bind: "127.0.0.1:22222".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
output: "stderr: second".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
];
let message = format_startup_failures("irtt", &failures);
assert!(message.contains("after 2 attempt(s)"));
assert!(message.contains("attempt 1/2"));
assert!(message.contains("attempt 2/2"));
assert!(message.contains("127.0.0.1:11111"));
assert!(message.contains("127.0.0.1:22222"));
assert!(message.contains("stdout: first"));
assert!(message.contains("stderr: second"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-proto" name = "irtt-proto"
version = "0.2.0" version = "0.3.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
-14
View File
@@ -31,20 +31,6 @@ mod tests {
use super::*; use super::*;
use crate::{hmac, FLAG_HMAC}; use crate::{hmac, FLAG_HMAC};
#[test]
fn close_request_sizes() {
let request = CloseRequest {
token: 0x7896_b6ab_8771_5213,
};
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
assert_eq!(
encode_close_request(&request, Some(b"testkey"))
.unwrap()
.len(),
28
);
}
#[test] #[test]
fn hmac_close_request_places_token_after_hmac() { fn hmac_close_request_places_token_after_hmac() {
let packet = encode_close_request( let packet = encode_close_request(
-46
View File
@@ -98,16 +98,6 @@ mod tests {
); );
} }
#[test]
fn compute_hmac_does_not_mutate_input() {
let packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
let original = packet.clone();
let _digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(packet, original);
}
#[test] #[test]
fn compute_hmac_matches_copy_and_zero_semantics() { fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]); let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
@@ -119,42 +109,6 @@ mod tests {
assert_eq!(digest, reference); assert_eq!(digest, reference);
} }
#[test]
fn compute_hmac_in_place_does_not_set_hmac_flag() {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&[1, 2, 3, 4]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(packet[3], FLAG_OPEN);
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
.iter()
.any(|byte| *byte != 0));
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
}
#[test]
fn compute_hmac_in_place_only_writes_hmac_field() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let original = packet.clone();
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
assert_ne!(
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
);
assert_eq!(
&packet[hmac_offset() + HMAC_SIZE..],
&original[hmac_offset() + HMAC_SIZE..]
);
}
#[test] #[test]
fn verify_hmac_rejects_modified_authenticated_byte() { fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]); let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
+6 -20
View File
@@ -155,6 +155,12 @@ mod tests {
#[test] #[test]
fn verified_layout_lengths() { fn verified_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
assert_eq!( assert_eq!(
echo_header_len( echo_header_len(
false, false,
@@ -199,16 +205,6 @@ mod tests {
); );
} }
#[test]
fn open_and_close_layout_lengths_account_for_hmac() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
}
#[test] #[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() { fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [ for stats in [
@@ -280,16 +276,6 @@ mod tests {
} }
} }
#[test]
fn no_timestamp_layout_ignores_clock() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_eq!(
echo_header_len(false, &params(ReceivedStats::None, StampAt::None, clock)),
16
);
}
}
#[test] #[test]
fn negotiated_length_never_smaller_than_header() { fn negotiated_length_never_smaller_than_header() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both); let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
+19 -1
View File
@@ -1,3 +1,21 @@
//! Low-level wire encode/decode support for the IRTT protocol.
//!
//! `irtt-proto` intentionally mirrors protocol fields closely: request/reply
//! structs expose wire-oriented values, flags, counters, timestamps, and
//! parameter enums rather than higher-level policy decisions. Higher-level
//! crates such as `irtt-client` are responsible for session behavior and
//! user-facing validation.
//!
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
//! `server_fill` values. Direct construction followed by [`Params::encode`]
//! performs no additional validation, so callers that build `Params` manually
//! are responsible for validating those values before sending them.
//!
//! The crate also provides packet layout calculation, open/echo/close packet
//! encoding and decoding, and optional HMAC placement, computation, and
//! verification helpers.
//!
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod close; pub mod close;
@@ -17,7 +35,7 @@ pub use flags::*;
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac}; pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
pub use layout::{echo_header_len, echo_packet_len, PacketLayout}; pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest}; pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt}; pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B]; pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
pub const PROTOCOL_VERSION: i64 = 1; pub const PROTOCOL_VERSION: i64 = 1;
+57 -7
View File
@@ -1,5 +1,16 @@
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION}; use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
/// Protocol compatibility bound for an encoded `server_fill` parameter.
pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// Low-level wire representation of IRTT open parameters.
///
/// `Params` mirrors the protocol fields closely and can be constructed
/// directly by callers. Direct construction can therefore produce values that
/// should not be sent on the wire, such as an oversized `server_fill` value.
/// Higher-level callers should validate user or configuration input before
/// encoding. The normal `irtt-client` configuration path enforces
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params { pub struct Params {
pub protocol_version: i64, pub protocol_version: i64,
@@ -21,6 +32,10 @@ impl Params {
} }
} }
/// Encodes these parameters without performing additional validation.
///
/// Callers that construct `Params` directly are responsible for validating
/// user or configuration input before encoding.
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new(); let mut out = Vec::new();
push_int(1, self.protocol_version, &mut out); push_int(1, self.protocol_version, &mut out);
@@ -39,6 +54,10 @@ impl Params {
out out
} }
/// Decodes parameters and rejects malformed or incompatible incoming values.
///
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
/// values longer than [`MAX_SERVER_FILL_BYTES`].
pub fn decode(input: &[u8]) -> Result<Self> { pub fn decode(input: &[u8]) -> Result<Self> {
let mut params = Self::default(); let mut params = Self::default();
let mut pos = 0; let mut pos = 0;
@@ -59,6 +78,12 @@ impl Params {
pos += used; pos += used;
let len = usize::try_from(len) let len = usize::try_from(len)
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?; .map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
if len > MAX_SERVER_FILL_BYTES {
return Err(ProtoError::ParameterLengthTooLarge {
tag,
length: len as u64,
});
}
if input.len().saturating_sub(pos) < len { if input.len().saturating_sub(pos) < len {
return Err(ProtoError::MalformedParams); return Err(ProtoError::MalformedParams);
} }
@@ -206,6 +231,14 @@ mod tests {
assert_eq!(Params::decode(&params.encode()), Ok(params)); assert_eq!(Params::decode(&params.encode()), Ok(params));
} }
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(value.len() as u64, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
#[test] #[test]
fn params_round_trip() { fn params_round_trip() {
let params = Params { let params = Params {
@@ -225,7 +258,7 @@ mod tests {
} }
#[test] #[test]
fn params_round_trip_received_stats_modes() { fn params_round_trip_negotiated_option_modes() {
for received_stats in [ for received_stats in [
ReceivedStats::None, ReceivedStats::None,
ReceivedStats::Count, ReceivedStats::Count,
@@ -238,10 +271,7 @@ mod tests {
..Params::default() ..Params::default()
}); });
} }
}
#[test]
fn params_round_trip_timestamp_modes() {
for stamp_at in [ for stamp_at in [
StampAt::None, StampAt::None,
StampAt::Send, StampAt::Send,
@@ -256,10 +286,7 @@ mod tests {
..Params::default() ..Params::default()
}); });
} }
}
#[test]
fn params_round_trip_clock_modes() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] { for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params { assert_round_trip(Params {
protocol_version: 1, protocol_version: 1,
@@ -323,6 +350,29 @@ mod tests {
}); });
} }
#[test]
fn server_fill_decode_accepts_max_length() {
let value = b"0123456789abcdef0123456789abcdef";
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
assert_eq!(
params.server_fill,
Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
})
);
}
#[test]
fn server_fill_decode_rejects_oversized_length() {
let value = b"0123456789abcdef0123456789abcdefx";
assert_eq!(
Params::decode(&encode_server_fill_value(value)),
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
);
}
#[test] #[test]
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() { fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
let params = Params { let params = Params {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-stats" name = "irtt-stats"
version = "0.2.0" version = "0.3.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
+16 -7
View File
@@ -69,8 +69,8 @@ impl CoreStats {
StatsEvent::UniqueReply { StatsEvent::UniqueReply {
is_late, sample, .. is_late, sample, ..
} => self.apply_unique_reply(is_late, *sample), } => self.apply_unique_reply(is_late, *sample),
StatsEvent::DuplicateReply { .. } => { StatsEvent::DuplicateReply { bytes, .. } => {
self.apply_duplicate_reply(); self.apply_duplicate_reply(bytes);
EventStatsUpdate::default() EventStatsUpdate::default()
} }
StatsEvent::Loss { .. } => { StatsEvent::Loss { .. } => {
@@ -81,8 +81,8 @@ impl CoreStats {
self.apply_warning(); self.apply_warning();
EventStatsUpdate::default() EventStatsUpdate::default()
} }
StatsEvent::UntrackedLate { .. } => { StatsEvent::UntrackedLate { bytes, .. } => {
self.apply_untracked_late(); self.apply_untracked_late(bytes);
EventStatsUpdate::default() EventStatsUpdate::default()
} }
} }
@@ -121,7 +121,12 @@ impl CoreStats {
.saturating_add(sample.bytes as u64); .saturating_add(sample.bytes as u64);
if let Some(count) = sample.received_count { if let Some(count) = sample.received_count {
self.packets.server_packets_received = Some(u64::from(count)); let count = u64::from(count);
self.packets.server_packets_received = Some(
self.packets
.server_packets_received
.map_or(count, |current| current.max(count)),
);
} }
if let Some(window) = sample.received_window { if let Some(window) = sample.received_window {
self.packets.server_received_window = Some(window); self.packets.server_received_window = Some(window);
@@ -181,10 +186,11 @@ impl CoreStats {
updates updates
} }
fn apply_duplicate_reply(&mut self) { fn apply_duplicate_reply(&mut self, bytes: usize) {
self.events.duplicate_replies += 1; self.events.duplicate_replies += 1;
self.packets.packets_received += 1; self.packets.packets_received += 1;
self.packets.duplicates += 1; self.packets.duplicates += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
} }
fn apply_loss(&mut self) { fn apply_loss(&mut self) {
@@ -195,8 +201,11 @@ impl CoreStats {
self.events.warning_events += 1; self.events.warning_events += 1;
} }
fn apply_untracked_late(&mut self) { fn apply_untracked_late(&mut self, bytes: usize) {
self.events.untracked_late_replies += 1; self.events.untracked_late_replies += 1;
self.packets.packets_received += 1;
self.packets.late_packets += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
} }
pub(crate) fn snapshot(&self) -> Snapshot { pub(crate) fn snapshot(&self) -> Snapshot {
+50 -8
View File
@@ -48,10 +48,8 @@ impl IpdvTracker {
if let Some(pair) = self.try_pair(seq) { if let Some(pair) = self.try_pair(seq) {
pairs.push(pair); pairs.push(pair);
} }
if let Some(next) = seq.checked_add(1) { if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
if let Some(pair) = self.try_pair(next) { pairs.push(pair);
pairs.push(pair);
}
} }
pairs pairs
} }
@@ -66,15 +64,13 @@ impl IpdvTracker {
}; };
if self.samples.remove(&seq).is_some() { if self.samples.remove(&seq).is_some() {
self.completed_pairs.remove(&seq); self.completed_pairs.remove(&seq);
if let Some(next) = seq.checked_add(1) { self.completed_pairs.remove(&seq.wrapping_add(1));
self.completed_pairs.remove(&next);
}
} }
} }
} }
fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> { fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> {
let previous_seq = current_seq.checked_sub(1)?; let previous_seq = current_seq.wrapping_sub(1);
if !self.completed_pairs.insert(current_seq) { if !self.completed_pairs.insert(current_seq) {
return None; return None;
@@ -237,6 +233,52 @@ mod tests {
); );
} }
#[test]
fn ipdv_tracker_completes_wrapped_adjacent_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX, 10)).is_empty());
let pairs = tracker.insert(ipdv_sample(0, 14));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_wrap_gap_does_not_complete_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX - 1, 10)).is_empty());
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
}
#[test]
fn ipdv_tracker_late_wrapped_previous_completes_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
let pairs = tracker.insert(ipdv_sample(u32::MAX, 10));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test] #[test]
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() { fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
let mut tracker = IpdvTracker::new(None); let mut tracker = IpdvTracker::new(None);
+15 -5
View File
@@ -198,6 +198,9 @@ pub struct EventCounts {
/// Warning events processed. /// Warning events processed.
pub warning_events: u64, pub warning_events: u64,
/// Late replies that could not be matched to retained in-flight state. /// Late replies that could not be matched to retained in-flight state.
///
/// These replies are observed on the socket, but cannot contribute RTT,
/// one-way delay, server-processing, or IPDV samples.
pub untracked_late_replies: u64, pub untracked_late_replies: u64,
} }
@@ -206,19 +209,26 @@ pub struct EventCounts {
pub struct PacketCounts { pub struct PacketCounts {
/// Probe packets sent by the local client. /// Probe packets sent by the local client.
pub packets_sent: u64, pub packets_sent: u64,
/// Reply packets received by the local client, including duplicates and late packets. /// Reply packets observed by the local client.
///
/// This includes unique measurable replies, duplicate replies, and late
/// replies that cannot be matched to retained in-flight state.
pub packets_received: u64, pub packets_received: u64,
/// Unique replies received by the local client. /// Unique replies that can contribute timing measurements.
pub unique_replies: u64, pub unique_replies: u64,
/// Duplicate replies received by the local client. /// Duplicate replies received by the local client.
pub duplicates: u64, pub duplicates: u64,
/// Late reply packets received by the local client. /// Late reply packets received by the local client, including untracked-late replies.
pub late_packets: u64, pub late_packets: u64,
/// Probe bytes sent by the local client. /// Probe bytes sent by the local client.
pub bytes_sent: u64, pub bytes_sent: u64,
/// Reply bytes received by the local client. /// Reply bytes observed by the local client.
///
/// This includes bytes from unique measurable replies, duplicate replies,
/// and untracked-late replies. Per-category byte counts are not currently
/// exposed separately.
pub bytes_received: u64, pub bytes_received: u64,
/// Server-reported packets received, when available. /// Highest cumulative server-reported packets received, when available.
pub server_packets_received: Option<u64>, pub server_packets_received: Option<u64>,
/// Server-reported receive window, when available. /// Server-reported receive window, when available.
pub server_received_window: Option<u64>, pub server_received_window: Option<u64>,
+17 -9
View File
@@ -22,6 +22,7 @@ pub(crate) enum StatsEvent {
}, },
DuplicateReply { DuplicateReply {
at: Instant, at: Instant,
bytes: usize,
}, },
Loss { Loss {
at: Instant, at: Instant,
@@ -31,6 +32,7 @@ pub(crate) enum StatsEvent {
}, },
UntrackedLate { UntrackedLate {
at: Instant, at: Instant,
bytes: usize,
}, },
} }
@@ -96,11 +98,17 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
*bytes, *bytes,
)), )),
}), }),
ClientEvent::LateReply { received_at, .. } => Some(StatsEvent::UntrackedLate { ClientEvent::LateReply {
received_at, bytes, ..
} => Some(StatsEvent::UntrackedLate {
at: received_at.mono, at: received_at.mono,
bytes: *bytes,
}), }),
ClientEvent::DuplicateReply { received_at, .. } => Some(StatsEvent::DuplicateReply { ClientEvent::DuplicateReply {
received_at, bytes, ..
} => Some(StatsEvent::DuplicateReply {
at: received_at.mono, at: received_at.mono,
bytes: *bytes,
}), }),
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }), ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }), ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
@@ -115,10 +123,10 @@ impl StatsEvent {
match self { match self {
Self::Sent { at, .. } Self::Sent { at, .. }
| Self::UniqueReply { at, .. } | Self::UniqueReply { at, .. }
| Self::DuplicateReply { at } | Self::DuplicateReply { at, .. }
| Self::Loss { at } | Self::Loss { at }
| Self::Warning { at } | Self::Warning { at }
| Self::UntrackedLate { at } => *at, | Self::UntrackedLate { at, .. } => *at,
} }
} }
} }
@@ -148,17 +156,17 @@ impl ReplySample {
received_stats: Option<ReceivedStatsSample>, received_stats: Option<ReceivedStatsSample>,
bytes: usize, bytes: usize,
) -> Self { ) -> Self {
let rtt_primary_ns = signed_duration_ns(rtt.effective_signed); let rtt_primary_ns = signed_duration_ns(rtt.effective);
Self { Self {
bytes, bytes,
rtt_raw_ns: duration_ns_i128(rtt.raw), rtt_raw_ns: duration_ns_i128(rtt.raw),
rtt_adjusted_ns: rtt.adjusted_signed.map(signed_duration_ns), rtt_adjusted_ns: rtt.adjusted.map(signed_duration_ns),
send_delay_ns: one_way send_delay_ns: one_way
.and_then(|sample| sample.client_to_server) .and_then(|sample| sample.client_to_server)
.map(duration_ns_i128), .map(signed_duration_ns),
receive_delay_ns: one_way receive_delay_ns: one_way
.and_then(|sample| sample.server_to_client) .and_then(|sample| sample.server_to_client)
.map(duration_ns_i128), .map(signed_duration_ns),
server_processing_ns: server_timing server_processing_ns: server_timing
.and_then(|timing| timing.processing) .and_then(|timing| timing.processing)
.map(duration_ns_i128), .map(duration_ns_i128),
@@ -195,7 +203,7 @@ fn duration_ns_i128(duration: Duration) -> i128 {
} }
fn signed_duration_ns(duration: SignedDuration) -> i128 { fn signed_duration_ns(duration: SignedDuration) -> i128 {
duration.ns duration.as_nanos()
} }
#[cfg(test)] #[cfg(test)]
+31 -18
View File
@@ -12,20 +12,21 @@ pub fn ts(ms: u64) -> ClientTimestamp {
} }
} }
pub fn rtt(raw_ms: u64, effective_ms: i128) -> RttSample { pub fn adjusted_rtt(raw_ms: u64, adjusted_ms: i128) -> RttSample {
let adjusted = SignedDuration::from_nanos(adjusted_ms * 1_000_000);
RttSample { RttSample {
raw: Duration::from_millis(raw_ms), raw: Duration::from_millis(raw_ms),
adjusted: u64::try_from(effective_ms).ok().map(Duration::from_millis), adjusted: Some(adjusted),
effective: u64::try_from(effective_ms) effective: adjusted,
.ok() }
.map(Duration::from_millis) }
.unwrap_or_else(|| Duration::from_millis(raw_ms)),
adjusted_signed: Some(SignedDuration { pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
ns: effective_ms * 1_000_000, let raw = Duration::from_millis(raw_ms);
}), RttSample {
effective_signed: SignedDuration { raw,
ns: effective_ms * 1_000_000, adjusted: None,
}, effective: SignedDuration::from_duration(raw),
} }
} }
@@ -41,7 +42,15 @@ pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
} }
} }
pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent { pub fn adjusted_reply(seq: u32, raw_ms: u64, adjusted_ms: i128) -> ClientEvent {
reply_with_rtt(seq, raw_ms, adjusted_rtt(raw_ms, adjusted_ms))
}
pub fn unadjusted_reply(seq: u32, raw_ms: u64) -> ClientEvent {
reply_with_rtt(seq, raw_ms, unadjusted_rtt(raw_ms))
}
fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> ClientEvent {
let sent_at = ts(seq as u64 * 10); let sent_at = ts(seq as u64 * 10);
let received_at = ClientTimestamp { let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(raw_ms), mono: sent_at.mono + Duration::from_millis(raw_ms),
@@ -52,7 +61,7 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
remote: "127.0.0.1:2112".parse().unwrap(), remote: "127.0.0.1:2112".parse().unwrap(),
sent_at, sent_at,
received_at, received_at,
rtt: rtt(raw_ms, effective_ms), rtt,
server_timing: Some(ServerTiming { server_timing: Some(ServerTiming {
receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000), receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000),
receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000), receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000),
@@ -63,8 +72,8 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
processing: Some(Duration::from_millis(1)), processing: Some(Duration::from_millis(1)),
}), }),
one_way: Some(OneWayDelaySample { one_way: Some(OneWayDelaySample {
client_to_server: Some(Duration::from_millis(1)), client_to_server: Some(SignedDuration::from_nanos(1_000_000)),
server_to_client: Some(Duration::from_millis(2)), server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
}), }),
received_stats: Some(ReceivedStatsSample { received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1), count: Some(seq + 1),
@@ -75,7 +84,11 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
} }
} }
pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent { pub fn unadjusted_late_reply(seq: u32, raw_ms: u64) -> ClientEvent {
late_reply_from(unadjusted_reply(seq, raw_ms))
}
fn late_reply_from(reply: ClientEvent) -> ClientEvent {
let ClientEvent::EchoReply { let ClientEvent::EchoReply {
seq, seq,
remote, remote,
@@ -88,7 +101,7 @@ pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
bytes, bytes,
packet_meta, packet_meta,
.. ..
} = reply(seq, raw_ms, effective_ms) } = reply
else { else {
unreachable!(); unreachable!();
}; };
+148 -26
View File
@@ -1,20 +1,23 @@
mod common; mod common;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant, UNIX_EPOCH};
use common::{late_reply, reply, sent, ts}; use common::{adjusted_reply, sent, ts, unadjusted_late_reply, unadjusted_reply};
use irtt_client::{ClientEvent, PacketMeta}; use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
};
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig}; use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
#[test] #[test]
fn running_only_samples_avoid_finite_retention() { fn running_only_samples_avoid_finite_retention() {
let mut collector = StatsCollector::new(StatsConfig::continuous()); let mut collector = StatsCollector::new(StatsConfig::continuous());
collector.process(&reply(0, 10, 9)); collector.process(&unadjusted_reply(0, 10));
collector.process(&reply(1, 20, 19)); collector.process(&unadjusted_reply(1, 20));
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.median_ns, None); assert_eq!(snapshot.rtt.primary.median_ns, None);
assert_eq!(snapshot.rtt.raw.median_ns, None); assert_eq!(snapshot.rtt.raw.median_ns, None);
assert_eq!(snapshot.rtt.adjusted.count, 0);
assert_eq!(snapshot.rtt.adjusted.median_ns, None); assert_eq!(snapshot.rtt.adjusted.median_ns, None);
assert_eq!(snapshot.ipdv.round_trip.median_ns, None); assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None); assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None);
@@ -25,7 +28,7 @@ fn running_only_samples_avoid_finite_retention() {
fn continuous_mode_tracks_running_samples_without_exact_medians() { fn continuous_mode_tracks_running_samples_without_exact_medians() {
let mut collector = StatsCollector::new(StatsConfig::continuous()); let mut collector = StatsCollector::new(StatsConfig::continuous());
for seq in 0..4104 { for seq in 0..4104 {
collector.process(&reply(seq, 10, 10)); collector.process(&unadjusted_reply(seq, 10));
} }
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
@@ -38,23 +41,24 @@ fn continuous_mode_tracks_running_samples_without_exact_medians() {
#[test] #[test]
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() { fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&reply(0, 1, -2)); collector.process(&adjusted_reply(0, 1, -2));
collector.process(&reply(1, 10, 8)); collector.process(&adjusted_reply(1, 10, 8));
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.count, 2); assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000)); assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000));
assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0)); assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0));
assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000); assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000);
assert_eq!(snapshot.rtt.adjusted.count, 2);
} }
#[test] #[test]
fn late_unique_counts_and_duplicates_do_not_update_measurements() { fn late_unique_counts_and_duplicates_do_not_update_duplicate_measurements() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0))); collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10))); collector.process(&sent(1, ts(10)));
collector.process(&reply(1, 10, 9)); collector.process(&unadjusted_reply(1, 10));
collector.process(&late_reply(0, 20, 19)); collector.process(&unadjusted_late_reply(0, 20));
collector.process(&ClientEvent::DuplicateReply { collector.process(&ClientEvent::DuplicateReply {
seq: 0, seq: 0,
remote: "127.0.0.1:2112".parse().unwrap(), remote: "127.0.0.1:2112".parse().unwrap(),
@@ -68,6 +72,7 @@ fn late_unique_counts_and_duplicates_do_not_update_measurements() {
assert_eq!(snapshot.packets.unique_replies, 2); assert_eq!(snapshot.packets.unique_replies, 2);
assert_eq!(snapshot.packets.duplicates, 1); assert_eq!(snapshot.packets.duplicates, 1);
assert_eq!(snapshot.packets.late_packets, 1); assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 64 + 64 + 64);
assert_eq!(snapshot.rtt.primary.count, 2); assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.loss.lost_packets, 0); assert_eq!(snapshot.loss.lost_packets, 0);
assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0); assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0);
@@ -83,7 +88,7 @@ fn final_loss_uses_sent_minus_unique_replies_not_echo_loss_events() {
sent_at: ts(0), sent_at: ts(0),
timeout_at: Instant::now(), timeout_at: Instant::now(),
}); });
collector.process(&late_reply(0, 10, 9)); collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
assert_eq!(snapshot.events.loss_events, 1); assert_eq!(snapshot.events.loss_events, 1);
@@ -110,12 +115,40 @@ fn assert_one_ipdv_pair(
pair pair
} }
fn reply_at(seq: u32, raw_ms: u64, sent_ms: u64) -> ClientEvent {
let sent_at = ClientTimestamp {
mono: Instant::now() + Duration::from_millis(sent_ms),
wall: UNIX_EPOCH + Duration::from_millis(sent_ms),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(raw_ms),
wall: sent_at.wall + Duration::from_millis(raw_ms),
};
let raw = Duration::from_millis(raw_ms);
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw,
adjusted: None,
effective: SignedDuration::from_duration(raw),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test] #[test]
fn ipdv_is_sequence_adjacent_and_gap_preserving() { fn ipdv_is_sequence_adjacent_and_gap_preserving() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&reply(0, 10, 10)); let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&reply(2, 15, 15)); let gap = collector.process(&unadjusted_reply(2, 15));
let adjacent = collector.process(&reply(3, 12, 12)); let adjacent = collector.process(&unadjusted_reply(3, 12));
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
assert!(first.contributed_sample); assert!(first.contributed_sample);
@@ -130,11 +163,44 @@ fn ipdv_is_sequence_adjacent_and_gap_preserving() {
assert_eq!(snapshot.ipdv.round_trip.total_ns, 3_000_000); assert_eq!(snapshot.ipdv.round_trip.total_ns, 3_000_000);
} }
#[test]
fn ipdv_wraparound_sequence_is_adjacent() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&reply_at(u32::MAX, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 10));
assert!(first.contributed_sample);
assert_no_ipdv_pairs(&first);
assert!(wrapped.contributed_sample);
assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4));
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 1);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000);
}
#[test]
fn ipdv_wraparound_gap_is_preserved() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let before_wrap = collector.process(&reply_at(u32::MAX - 1, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 20));
assert!(before_wrap.contributed_sample);
assert_no_ipdv_pairs(&before_wrap);
assert!(wrapped.contributed_sample);
assert_no_ipdv_pairs(&wrapped);
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 0);
}
#[test] #[test]
fn late_reply_can_complete_ipdv_pair() { fn late_reply_can_complete_ipdv_pair() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&reply(1, 20, 20)); collector.process(&unadjusted_reply(1, 20));
let update = collector.process(&late_reply(0, 10, 10)); let update = collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
@@ -148,8 +214,8 @@ fn late_reply_can_complete_ipdv_pair() {
#[test] #[test]
fn update_exposes_directional_ipdv_when_available() { fn update_exposes_directional_ipdv_when_available() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&reply(0, 10, 10)); collector.process(&unadjusted_reply(0, 10));
let update = collector.process(&reply(1, 13, 13)); let update = collector.process(&unadjusted_reply(1, 13));
assert!(update.contributed_sample); assert!(update.contributed_sample);
@@ -162,9 +228,9 @@ fn update_exposes_directional_ipdv_when_available() {
fn gap_fill_update_exposes_both_completed_ipdv_pairs() { fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&reply(0, 10, 10)); let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&reply(2, 20, 20)); let gap = collector.process(&unadjusted_reply(2, 20));
let fill = collector.process(&reply(1, 13, 13)); let fill = collector.process(&unadjusted_reply(1, 13));
assert!(first.contributed_sample); assert!(first.contributed_sample);
assert!(first.ipdv_pairs.is_empty()); assert!(first.ipdv_pairs.is_empty());
@@ -191,7 +257,7 @@ fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
#[test] #[test]
fn server_processing_and_one_way_require_available_samples() { fn server_processing_and_one_way_require_available_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&reply(0, 10, 9)); collector.process(&unadjusted_reply(0, 10));
collector.process(&ClientEvent::LateReply { collector.process(&ClientEvent::LateReply {
seq: 9, seq: 9,
highest_seen: 10, highest_seen: 10,
@@ -210,6 +276,62 @@ fn server_processing_and_one_way_require_available_samples() {
assert_eq!(snapshot.server_processing.processing.count, 1); assert_eq!(snapshot.server_processing.processing.count, 1);
assert_eq!(snapshot.one_way_delay.send_delay.count, 1); assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.events.untracked_late_replies, 1); assert_eq!(snapshot.events.untracked_late_replies, 1);
assert_eq!(snapshot.packets.packets_received, 2);
assert_eq!(snapshot.packets.unique_replies, 1);
assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 128);
}
#[test]
fn negative_one_way_delay_samples_are_included_in_stats() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = ts(0);
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(10),
wall: sent_at.wall + Duration::from_millis(10),
};
collector.process(&ClientEvent::EchoReply {
seq: 0,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_millis(10),
adjusted: None,
effective: SignedDuration::from_nanos(10_000_000),
},
server_timing: None,
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(-1_000_000)),
server_to_client: Some(SignedDuration::from_nanos(-2_000_000)),
}),
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let snapshot = collector.snapshot();
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.one_way_delay.send_delay.min_ns, Some(-1_000_000));
assert_eq!(snapshot.one_way_delay.send_delay.total_ns, -1_000_000);
assert_eq!(snapshot.one_way_delay.receive_delay.count, 1);
assert_eq!(
snapshot.one_way_delay.receive_delay.min_ns,
Some(-2_000_000)
);
assert_eq!(snapshot.one_way_delay.receive_delay.total_ns, -2_000_000);
}
#[test]
fn older_cumulative_server_receive_count_does_not_regress() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(1, 10));
collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot();
assert_eq!(snapshot.packets.server_packets_received, Some(2));
} }
#[test] #[test]
@@ -220,8 +342,8 @@ fn rolling_count_eviction_recomputes_from_bounded_events() {
rolling_time: None, rolling_time: None,
}); });
collector.process(&sent(0, ts(0))); collector.process(&sent(0, ts(0)));
collector.process(&reply(0, 10, 10)); collector.process(&unadjusted_reply(0, 10));
collector.process(&reply(1, 20, 20)); collector.process(&unadjusted_reply(1, 20));
let rolling = collector.rolling_count().unwrap(); let rolling = collector.rolling_count().unwrap();
assert_eq!(rolling.packets.packets_sent, 0); assert_eq!(rolling.packets.packets_sent, 0);
@@ -261,7 +383,7 @@ fn directional_loss_uses_server_received_count_when_available() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0))); collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10))); collector.process(&sent(1, ts(10)));
collector.process(&reply(0, 10, 10)); collector.process(&unadjusted_reply(0, 10));
let loss = collector.snapshot().loss; let loss = collector.snapshot().loss;
assert_eq!(loss.lost_packets, 1); assert_eq!(loss.lost_packets, 1);
+2 -1
View File
@@ -4,10 +4,11 @@ members = ["cargo:."]
# Config for 'dist' # Config for 'dist'
[dist] [dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax) # The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.31.0" cargo-dist-version = "0.32.0"
# CI backends to support # CI backends to support
ci = "github" ci = "github"
# The installers to generate for each app # The installers to generate for each app
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"]
+143
View File
@@ -0,0 +1,143 @@
# Interop Comparison
`scripts/compare_irtt_clients.py` is a black-box comparison harness for running
upstream `irtt` and `irtt-rs` clients concurrently against the same server. It
captures raw stdout/stderr, upstream JSON, parsed RTT samples, per-case
summaries, and comparison JSON under `target/interop-comparisons` by default.
The harness does not inspect upstream source or tests. It only invokes the
upstream executable.
## Remote or Existing Local Server
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
```sh
cargo build -p irtt-cli
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
```
Run the default comparison set:
```sh
python3 scripts/compare_irtt_clients.py --upstream-irtt "$HOME/go/bin/irtt"
```
To compare against a specific target:
```sh
python3 scripts/compare_irtt_clients.py \
--target netperf-eu.bufferbloat.net \
--duration 15s \
--interval 250ms \
--upstream-irtt "$HOME/go/bin/irtt"
```
Remote comparisons are useful for smoke coverage, but Internet paths are noisy
and can make RTT/loss differences hard to interpret.
## Linux Netem Interop
`scripts/run_netem_interop_ci.py` provides deterministic Linux CI coverage using
network namespaces, a veth pair, and `tc netem`. It is intended for GitHub
Actions Ubuntu runners or a Linux host with passwordless `sudo` for `ip` and
`tc`.
The harness creates two namespaces:
- `irtt-client` with `veth-client` at `10.10.0.1/24`
- `irtt-server` with `veth-server` at `10.10.0.2/24`
It runs the upstream server in the server namespace:
```sh
irtt server -b 10.10.0.2:2112 --tstamp=dual
```
Both clients run in the client namespace through the existing comparison
harness:
```sh
sudo ip netns exec irtt-client ...
```
Netem qdiscs are applied on veth egress:
- `veth-client` controls client-to-server delay/loss
- `veth-server` controls server-to-client delay/loss
Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
a `finally` path.
### Scenarios
The CI harness implements:
| scenario | client-to-server | server-to-client | duration | interval | policy |
| --- | --- | --- | --- | --- | --- |
| `baseline-no-netem` | none | none | `5s` | `100ms` | deterministic |
| `symmetric-20ms` | `delay 20ms` | `delay 20ms` | `5s` | `100ms` | deterministic |
| `symmetric-50ms-jitter` | `delay 50ms 5ms` | `delay 50ms 5ms` | `5s` | `100ms` | jitter |
| `asymmetric-delay` | `delay 40ms` | `delay 10ms` | `5s` | `100ms` | deterministic |
| `packet-loss` | `loss 5%` | `loss 5%` | `10s` | `50ms` | stochastic loss |
Asymmetric one-way delay fields include client/server wall-clock offset. Since
both clients run in the same client namespace against the same server namespace,
relative one-way shape is still useful for diagnostics, but the CI pass/fail
policy is based on RTT and packet behavior.
### Pass/Fail Policy
Deterministic scenarios fail when:
- either client exits non-zero
- parsed mean RTT or received packet counts are missing
- either client receives no packets
- unexpected loss exceeds `0.5%`
- mean RTT delta exceeds `max(2000us, 5% of expected RTT)`
The jitter scenario fails on crashes, parser failures, no received packets, or
mean RTT delta above `max(8000us, 10% of expected RTT)`.
The packet-loss scenario fails on crashes, parser failures, or no received
packets. Loss-rate deltas above `10%` are warnings because packet loss is
stochastic and exact percentages are not stable enough for CI.
Thresholds can be adjusted with:
```sh
python3 scripts/run_netem_interop_ci.py \
--mean-abs-tolerance-us 3000 \
--mean-pct-tolerance 0.08 \
--jitter-mean-abs-tolerance-us 10000 \
--loss-rate-warning-pct 12
```
### Manual Linux Usage
On Linux:
```sh
cargo build -p irtt-cli
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$HOME/go/bin/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
```
Run one scenario:
```sh
python3 scripts/run_netem_interop_ci.py --scenario symmetric-20ms
```
Artifacts are written under `target/interop-netem/<timestamp>/` by default. Each
scenario contains the existing comparison harness output under
`<scenario>/comparison/<timestamp>/`, plus netem-specific `summary.md` and
`netem-summary.json` files. Server stdout/stderr are captured at the run root as
`server.stdout` and `server.stderr`.
On macOS, `ip netns` and `tc netem` are unavailable; use the GitHub Actions
workflow for netem coverage.
+1143
View File
File diff suppressed because it is too large. Load diff
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""Run local netem-based IRTT client interop comparisons on Linux.
The harness creates two network namespaces connected by a veth pair, runs an
upstream irtt server in the server namespace, and runs the existing paired
client comparison harness in the client namespace.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import platform
import re
import shutil
import shlex
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
COMPARE_SCRIPT = REPO_ROOT / "scripts" / "compare_irtt_clients.py"
DEFAULT_OUTPUT_ROOT = REPO_ROOT / "target" / "interop-netem"
CLIENT_NS = "irtt-client"
SERVER_NS = "irtt-server"
CLIENT_VETH = "veth-client"
SERVER_VETH = "veth-server"
CLIENT_ADDR = "10.10.0.1/24"
SERVER_ADDR = "10.10.0.2/24"
SERVER_TARGET = "10.10.0.2:2112"
@dataclass(frozen=True)
class Scenario:
name: str
duration: str
interval: str
client_to_server: str
server_to_client: str
expected_rtt_us: float | None
mode: str
SCENARIOS = {
scenario.name: scenario
for scenario in [
Scenario("baseline-no-netem", "5s", "100ms", "", "", None, "deterministic"),
Scenario("symmetric-20ms", "5s", "100ms", "delay 20ms", "delay 20ms", 40_000, "deterministic"),
Scenario(
"symmetric-50ms-jitter",
"5s",
"100ms",
"delay 50ms 5ms",
"delay 50ms 5ms",
100_000,
"jitter",
),
Scenario("asymmetric-delay", "5s", "100ms", "delay 40ms", "delay 10ms", 50_000, "deterministic"),
Scenario("packet-loss", "10s", "50ms", "loss 5%", "loss 5%", None, "loss"),
]
}
class CommandError(RuntimeError):
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str):
self.command = command
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
super().__init__(f"{shlex.join(command)} exited {returncode}")
def utc_now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run upstream irtt vs irtt-rs comparisons inside netem namespaces."
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--scenario",
action="append",
choices=sorted(SCENARIOS),
help="Scenario to run. Repeatable. Defaults to baseline-no-netem unless --all is set.",
)
group.add_argument("--all", action="store_true", help="Run all netem scenarios.")
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_ROOT),
help=f"Artifact root directory (default: {DEFAULT_OUTPUT_ROOT}).",
)
parser.add_argument(
"--upstream-irtt",
default="irtt",
help="Path to upstream irtt executable (default: irtt from PATH).",
)
parser.add_argument(
"--irtt-rs-command",
default=str(REPO_ROOT / "target" / "debug" / "irtt-cli"),
help="Path or command for irtt-rs client (default: target/debug/irtt-cli).",
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Do not run cargo build -p irtt-cli before comparisons.",
)
parser.add_argument(
"--sudo",
default="sudo",
help="Privilege escalation command for ip/tc namespace operations (default: sudo).",
)
parser.add_argument(
"--mean-abs-tolerance-us",
type=float,
default=2_000.0,
help="Minimum deterministic mean RTT delta tolerance in microseconds.",
)
parser.add_argument(
"--mean-pct-tolerance",
type=float,
default=0.05,
help="Deterministic mean RTT delta tolerance as a fraction of expected RTT.",
)
parser.add_argument(
"--jitter-mean-abs-tolerance-us",
type=float,
default=8_000.0,
help="Minimum jitter scenario mean RTT delta fail tolerance in microseconds.",
)
parser.add_argument(
"--loss-rate-warning-pct",
type=float,
default=10.0,
help="Packet-loss scenario warning threshold for client loss-rate delta.",
)
parser.add_argument(
"--loss-abs-tolerance-pct",
type=float,
default=8.0,
help="Packet-loss scenario absolute tolerance from expected RTT loss percentage.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
selected = select_scenarios(args)
run_dir = Path(args.output_dir) / dt.datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "run-info.json").write_text(
json.dumps(
{
"created_at": utc_now(),
"scenarios": [scenario.name for scenario in selected],
"client_namespace": CLIENT_NS,
"server_namespace": SERVER_NS,
"target": SERVER_TARGET,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
try:
preflight(args)
upstream_irtt = resolve_command(args.upstream_irtt)
irtt_rs_command = resolve_irtt_rs_command(args.irtt_rs_command)
if not args.skip_build:
run_checked(["cargo", "build", "-p", "irtt-cli"], run_dir / "cargo-build")
cleanup(args, quiet=True)
setup_namespaces(args, run_dir)
server_proc = start_server(args, upstream_irtt, run_dir)
try:
wait_for_server(args, upstream_irtt, run_dir)
results = []
all_ok = True
for scenario in selected:
result = run_scenario(args, scenario, upstream_irtt, irtt_rs_command, run_dir)
results.append(result)
all_ok = all_ok and result["classification"] != "fail"
write_run_summary(run_dir, results)
return 0 if all_ok else 1
finally:
stop_process(server_proc)
except Exception as exc:
(run_dir / "error.txt").write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8")
print(f"error: {exc}", file=sys.stderr)
return 2
finally:
cleanup(args, quiet=True)
print(f"[{utc_now()}] wrote netem artifacts to {run_dir}", flush=True)
def select_scenarios(args: argparse.Namespace) -> list[Scenario]:
if args.all:
return [SCENARIOS[name] for name in sorted(SCENARIOS)]
names = args.scenario or ["baseline-no-netem"]
return [SCENARIOS[name] for name in names]
def preflight(args: argparse.Namespace) -> None:
if platform.system() != "Linux":
raise RuntimeError("netem interop requires Linux network namespaces and tc netem")
for binary in ["ip", "tc"]:
if shutil.which(binary) is None:
raise RuntimeError(f"required command not found in PATH: {binary}")
if args.sudo and shutil.which(args.sudo) is None:
raise RuntimeError(f"sudo command not found in PATH: {args.sudo}")
if not COMPARE_SCRIPT.exists():
raise RuntimeError(f"missing comparison harness: {COMPARE_SCRIPT}")
def resolve_command(command: str) -> str:
parts = shlex.split(command)
if len(parts) != 1:
raise RuntimeError(f"expected a single executable path, got: {command}")
executable = parts[0]
if os.sep in executable:
return str(Path(executable).resolve())
resolved = shutil.which(executable)
if resolved is None:
raise RuntimeError(f"executable not found in PATH: {executable}")
return resolved
def resolve_irtt_rs_command(command: str) -> str:
parts = shlex.split(command)
if not parts:
raise RuntimeError("--irtt-rs-command must not be empty")
executable = parts[0]
if os.sep in executable:
parts[0] = str(Path(executable).resolve())
else:
resolved = shutil.which(executable)
if resolved is not None:
parts[0] = resolved
return shlex.join(parts)
def sudo_prefix(args: argparse.Namespace) -> list[str]:
return [args.sudo] if args.sudo else []
def netns(args: argparse.Namespace, namespace: str, command: list[str]) -> list[str]:
return [*sudo_prefix(args), "ip", "netns", "exec", namespace, *command]
def run_checked(
command: list[str],
log_prefix: Path,
check: bool = True,
ignored_stderr_texts: list[str] | None = None,
) -> subprocess.CompletedProcess[str]:
print(f"[{utc_now()}] {shlex.join(command)}", flush=True)
completed = subprocess.run(
command,
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stderr = filter_stderr(completed.stderr, ignored_stderr_texts or [])
log_prefix.parent.mkdir(parents=True, exist_ok=True)
(log_prefix.with_suffix(".stdout")).write_text(completed.stdout, encoding="utf-8")
if stderr or not ignored_stderr_texts:
(log_prefix.with_suffix(".stderr")).write_text(stderr, encoding="utf-8")
(log_prefix.with_suffix(".command")).write_text(shlex.join(command) + "\n", encoding="utf-8")
if check and completed.returncode != 0:
raise CommandError(command, completed.returncode, completed.stdout, stderr)
return completed
def filter_stderr(stderr: str, ignored_texts: list[str]) -> str:
if not stderr or not ignored_texts:
return stderr
kept = [line for line in stderr.splitlines() if not any(text in line for text in ignored_texts)]
if stderr.endswith("\n") and kept:
return "\n".join(kept) + "\n"
return "\n".join(kept)
def setup_namespaces(args: argparse.Namespace, run_dir: Path) -> None:
commands = [
[*sudo_prefix(args), "ip", "netns", "add", CLIENT_NS],
[*sudo_prefix(args), "ip", "netns", "add", SERVER_NS],
[*sudo_prefix(args), "ip", "link", "add", CLIENT_VETH, "type", "veth", "peer", "name", SERVER_VETH],
[*sudo_prefix(args), "ip", "link", "set", CLIENT_VETH, "netns", CLIENT_NS],
[*sudo_prefix(args), "ip", "link", "set", SERVER_VETH, "netns", SERVER_NS],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "addr", "add", CLIENT_ADDR, "dev", CLIENT_VETH],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "addr", "add", SERVER_ADDR, "dev", SERVER_VETH],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", CLIENT_VETH, "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", SERVER_VETH, "up"],
]
for index, command in enumerate(commands):
run_checked(command, run_dir / "setup" / f"{index:02d}")
def start_server(args: argparse.Namespace, upstream_irtt: str, run_dir: Path) -> subprocess.Popen[bytes]:
command = netns(
args,
SERVER_NS,
[upstream_irtt, "server", "-b", SERVER_TARGET, "--tstamp=dual"],
)
(run_dir / "server.command").write_text(shlex.join(command) + "\n", encoding="utf-8")
stdout = (run_dir / "server.stdout").open("wb")
stderr = (run_dir / "server.stderr").open("wb")
print(f"[{utc_now()}] starting server: {shlex.join(command)}", flush=True)
try:
return subprocess.Popen(command, cwd=REPO_ROOT, stdout=stdout, stderr=stderr)
except Exception:
stdout.close()
stderr.close()
raise
def wait_for_server(args: argparse.Namespace, upstream_irtt: str, run_dir: Path) -> None:
command = netns(
args,
CLIENT_NS,
[upstream_irtt, "client", "-n", "-Q", "--timeouts=1s", SERVER_TARGET],
)
deadline = time.monotonic() + 10.0
attempt = 0
last: subprocess.CompletedProcess[str] | None = None
while time.monotonic() < deadline:
attempt += 1
last = run_checked(command, run_dir / "server-ready" / f"attempt-{attempt:02d}", check=False)
if last.returncode == 0:
return
time.sleep(0.5)
details = ""
if last is not None:
details = (last.stderr or last.stdout).strip()
raise RuntimeError(f"upstream irtt server did not become reachable: {details}")
def run_scenario(
args: argparse.Namespace,
scenario: Scenario,
upstream_irtt: str,
irtt_rs_command: str,
run_dir: Path,
) -> dict[str, Any]:
scenario_dir = run_dir / scenario.name
scenario_dir.mkdir(parents=True, exist_ok=True)
apply_netem(args, scenario, scenario_dir)
compare_root = scenario_dir / "comparison"
command = [
sys.executable,
str(COMPARE_SCRIPT),
"--target",
SERVER_TARGET,
"--duration",
scenario.duration,
"--interval",
scenario.interval,
"--output-dir",
str(compare_root),
"--upstream-irtt",
upstream_irtt,
"--irtt-rs-command",
irtt_rs_command,
"--client-command-prefix",
shlex.join(netns(args, CLIENT_NS, [])),
"--skip-build",
]
completed = run_checked(command, scenario_dir / "compare", check=False)
comparison = load_latest_comparison(compare_root)
result = classify_scenario(args, scenario, completed.returncode, comparison)
result.update(
{
"scenario": scenario.__dict__,
"qdisc": {
"client_to_server": scenario.client_to_server or "none",
"server_to_client": scenario.server_to_client or "none",
},
"comparison_returncode": completed.returncode,
"comparison_root": str(compare_root),
"comparison_json": comparison.get("_path"),
}
)
write_json(scenario_dir / "netem-summary.json", result)
write_scenario_markdown(scenario_dir / "summary.md", result)
return result
def apply_netem(args: argparse.Namespace, scenario: Scenario, scenario_dir: Path) -> None:
delete_qdiscs(args, scenario_dir)
commands: list[tuple[str, str, str]] = [
(CLIENT_NS, CLIENT_VETH, scenario.client_to_server),
(SERVER_NS, SERVER_VETH, scenario.server_to_client),
]
for namespace, dev, setting in commands:
if not setting:
continue
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc",
"qdisc",
"replace",
"dev",
dev,
"root",
"netem",
*shlex.split(setting),
]
run_checked(command, scenario_dir / "netem" / f"{namespace}-{dev}")
def delete_qdiscs(args: argparse.Namespace, log_dir: Path) -> None:
for namespace, dev in [(CLIENT_NS, CLIENT_VETH), (SERVER_NS, SERVER_VETH)]:
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc",
"qdisc",
"del",
"dev",
dev,
"root",
]
run_checked(
command,
log_dir / "qdisc-cleanup" / f"{namespace}-{dev}",
check=False,
ignored_stderr_texts=["Cannot delete qdisc with handle of zero"],
)
def load_latest_comparison(compare_root: Path) -> dict[str, Any]:
candidates = sorted(compare_root.glob("*/comparison.json"), key=lambda path: path.stat().st_mtime)
if not candidates:
return {"_path": None, "results": [], "local_error": "comparison.json not found"}
path = candidates[-1]
data = json.loads(path.read_text(encoding="utf-8"))
data["_path"] = str(path)
return data
def classify_scenario(
args: argparse.Namespace,
scenario: Scenario,
returncode: int,
comparison: dict[str, Any],
) -> dict[str, Any]:
notes: list[str] = []
warnings: list[str] = []
failures: list[str] = []
result = first_comparison_result(comparison)
upstream = result.get("metrics", {}).get("upstream", {}) if result else {}
rust = result.get("metrics", {}).get("irtt_rs", {}) if result else {}
if returncode != 0:
failures.append(f"comparison harness exited {returncode}")
if not result:
failures.append("comparison result missing")
for label, metrics in [("upstream", upstream), ("irtt-rs", rust)]:
if metrics.get("rtt_mean_us") is None:
failures.append(f"{label} mean RTT missing")
if (metrics.get("received") or 0) <= 0:
failures.append(f"{label} received no packets")
mean_delta_us = None
if upstream.get("rtt_mean_us") is not None and rust.get("rtt_mean_us") is not None:
mean_delta_us = rust["rtt_mean_us"] - upstream["rtt_mean_us"]
baseline = scenario.expected_rtt_us or max(abs(upstream["rtt_mean_us"]), 1.0)
if scenario.mode == "deterministic":
tolerance = max(args.mean_abs_tolerance_us, args.mean_pct_tolerance * baseline)
if abs(mean_delta_us) > tolerance:
failures.append(
f"mean RTT delta {mean_delta_us:.1f}us exceeds deterministic tolerance {tolerance:.1f}us"
)
elif scenario.mode == "jitter":
tolerance = max(args.jitter_mean_abs_tolerance_us, 0.10 * baseline)
if abs(mean_delta_us) > tolerance:
failures.append(
f"mean RTT delta {mean_delta_us:.1f}us exceeds jitter tolerance {tolerance:.1f}us"
)
if scenario.mode == "deterministic":
for label, metrics in [("upstream", upstream), ("irtt-rs", rust)]:
loss = metrics.get("loss_percent")
if loss is not None and loss > 0.5:
failures.append(f"{label} observed unexpected loss {loss:.3f}%")
elif scenario.mode == "loss":
upstream_loss = upstream.get("loss_percent")
rust_loss = rust.get("loss_percent")
expected_loss = expected_rtt_loss_percent(scenario)
if upstream_loss is not None and rust_loss is not None:
loss_delta = rust_loss - upstream_loss
if abs(loss_delta) > args.loss_rate_warning_pct:
warnings.append(
f"loss-rate delta {loss_delta:.3f}% exceeds warning threshold "
f"{args.loss_rate_warning_pct:.3f}%"
)
for label, observed_loss in [("upstream", upstream_loss), ("irtt-rs", rust_loss)]:
if observed_loss is None:
failures.append(f"{label} loss rate missing")
elif observed_loss <= 0.0:
failures.append(f"{label} observed no loss in loss scenario")
elif abs(observed_loss - expected_loss) > args.loss_abs_tolerance_pct:
failures.append(
f"{label} loss {observed_loss:.3f}% outside expected "
f"{expected_loss:.3f}% +/- {args.loss_abs_tolerance_pct:.3f}%"
)
comparison_notes = result.get("comparison", {}).get("notes", []) if result else []
notes.extend(comparison_notes)
classification = "fail" if failures else ("warning" if warnings else "pass")
return {
"classification": classification,
"failures": failures,
"warnings": warnings,
"notes": sorted(set(notes)),
"mean_rtt_delta_us": mean_delta_us,
"expected_loss_percent": expected_rtt_loss_percent(scenario) if scenario.mode == "loss" else None,
"upstream": upstream,
"irtt_rs": rust,
}
def expected_rtt_loss_percent(scenario: Scenario) -> float:
client_to_server_loss = netem_loss_percent(scenario.client_to_server)
server_to_client_loss = netem_loss_percent(scenario.server_to_client)
success_probability = (1.0 - client_to_server_loss / 100.0) * (1.0 - server_to_client_loss / 100.0)
return 100.0 * (1.0 - success_probability)
def netem_loss_percent(setting: str) -> float:
match = re.search(r"(?:^|\s)loss\s+([0-9]+(?:\.[0-9]+)?)%", setting)
if match is None:
return 0.0
return float(match.group(1))
def first_comparison_result(comparison: dict[str, Any]) -> dict[str, Any] | None:
results = comparison.get("results")
if isinstance(results, list) and results:
first = results[0]
if isinstance(first, dict):
return first
return None
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_scenario_markdown(path: Path, result: dict[str, Any]) -> None:
lines = [
f"# {result['scenario']['name']}",
"",
f"classification: `{result['classification']}`",
f"client-to-server netem: `{result['qdisc']['client_to_server']}`",
f"server-to-client netem: `{result['qdisc']['server_to_client']}`",
f"comparison_json: `{result.get('comparison_json')}`",
f"mean_rtt_delta_us: `{format_optional(result.get('mean_rtt_delta_us'))}`",
f"expected_loss_percent: `{format_optional(result.get('expected_loss_percent'))}`",
"",
"| client | sent | received | loss % | mean RTT us |",
"| --- | ---: | ---: | ---: | ---: |",
markdown_metric_row("upstream", result["upstream"]),
markdown_metric_row("irtt-rs", result["irtt_rs"]),
]
if result["failures"]:
lines.extend(["", "## Failures"])
lines.extend(f"- {item}" for item in result["failures"])
if result["warnings"]:
lines.extend(["", "## Warnings"])
lines.extend(f"- {item}" for item in result["warnings"])
if result["notes"]:
lines.extend(["", "## Comparison Notes"])
lines.extend(f"- {item}" for item in result["notes"])
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def markdown_metric_row(label: str, metrics: dict[str, Any]) -> str:
return (
f"| {label} | {format_optional(metrics.get('sent'))} | "
f"{format_optional(metrics.get('received'))} | "
f"{format_optional(metrics.get('loss_percent'))} | "
f"{format_optional(metrics.get('rtt_mean_us'))} |"
)
def format_optional(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, float):
return f"{value:.3f}"
return str(value)
def write_run_summary(run_dir: Path, results: list[dict[str, Any]]) -> None:
write_json(run_dir / "netem-summary.json", {"results": results})
(run_dir / "summary.md").write_text(
build_run_summary_markdown(results, link_scenarios=True, include_artifact_note=False),
encoding="utf-8",
)
(run_dir / "github-step-summary.md").write_text(
build_run_summary_markdown(results, link_scenarios=False, include_artifact_note=True),
encoding="utf-8",
)
def build_run_summary_markdown(
results: list[dict[str, Any]],
*,
link_scenarios: bool,
include_artifact_note: bool,
) -> str:
lines = [
"# Netem IRTT Interop Run",
"",
]
if include_artifact_note:
lines.extend(
[
"Detailed per-scenario files are available in the uploaded `interop-netem` artifact.",
"",
]
)
lines.extend(
[
"| scenario | classification | expected RTT us | expected loss % | upstream median RTT us | irtt-rs median RTT us | upstream loss % | irtt-rs loss % | mean RTT delta us |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
]
)
for result in results:
scenario = result["scenario"]
upstream = result["upstream"]
rust = result["irtt_rs"]
scenario_name = scenario["name"]
scenario_cell = f"[{scenario_name}]({scenario_name}/summary.md)" if link_scenarios else scenario_name
lines.append(
f"| {scenario_cell} | "
f"{result['classification']} | "
f"{format_optional(scenario.get('expected_rtt_us'))} | "
f"{format_optional(result.get('expected_loss_percent'))} | "
f"{format_optional(upstream.get('rtt_median_us'))} | "
f"{format_optional(rust.get('rtt_median_us'))} | "
f"{format_optional(upstream.get('loss_percent'))} | "
f"{format_optional(rust.get('loss_percent'))} | "
f"{format_optional(result.get('mean_rtt_delta_us'))} |"
)
failures = [(result["scenario"]["name"], item) for result in results for item in result["failures"]]
warnings = [(result["scenario"]["name"], item) for result in results for item in result["warnings"]]
if failures:
lines.extend(["", "## Failures"])
lines.extend(format_run_summary_item(scenario, item, link_scenarios) for scenario, item in failures)
if warnings:
lines.extend(["", "## Warnings"])
lines.extend(format_run_summary_item(scenario, item, link_scenarios) for scenario, item in warnings)
return "\n".join(lines) + "\n"
def format_run_summary_item(scenario: str, item: str, link_scenarios: bool) -> str:
scenario_label = f"[{scenario}]({scenario}/summary.md)" if link_scenarios else scenario
return f"- {scenario_label}: {item}"
def stop_process(proc: subprocess.Popen[bytes]) -> None:
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.send_signal(signal.SIGKILL)
proc.wait(timeout=5)
def cleanup(args: argparse.Namespace, quiet: bool) -> None:
if shutil.which("ip") is None:
return
if args.sudo and shutil.which(args.sudo) is None:
return
commands = [
[*sudo_prefix(args), "ip", "netns", "exec", CLIENT_NS, "tc", "qdisc", "del", "dev", CLIENT_VETH, "root"],
[*sudo_prefix(args), "ip", "netns", "exec", SERVER_NS, "tc", "qdisc", "del", "dev", SERVER_VETH, "root"],
[*sudo_prefix(args), "ip", "netns", "delete", CLIENT_NS],
[*sudo_prefix(args), "ip", "netns", "delete", SERVER_NS],
]
for command in commands:
completed = subprocess.run(
command,
cwd=REPO_ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not quiet and completed.returncode != 0:
print(f"cleanup command failed: {shlex.join(command)}", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())