Compare commits
54
Commits
stats-test
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352cebb5c3 | ||
|
|
58fd017d4f | ||
|
|
5efb8a1870 | ||
|
|
f41fd4a8e7 | ||
|
|
5de1bf7343 | ||
|
|
7e89d11e6e | ||
|
|
196fc29f76 | ||
|
|
ded8a5f87d | ||
|
|
133ff71b72 | ||
|
|
d7cdcc5cee | ||
|
|
c31ad07d61 | ||
|
|
ece9fd6db0 | ||
|
|
9b0cc2b395 | ||
|
|
b544df7cc9 | ||
|
|
5646365677 | ||
|
|
64be840c1e | ||
|
|
b8b0425c4e | ||
|
|
3e07732934 | ||
|
|
61a242c5d4 | ||
|
|
6bb4e70df3 | ||
|
|
c739764476 | ||
|
|
5c96024c4f | ||
|
|
5d653dac13 | ||
|
|
6f77d38a7f | ||
|
|
01c10e2e0d | ||
|
|
8fd0b803e9 | ||
|
|
614ad5b5cd | ||
|
|
2fcef0adc5 | ||
|
|
c7a0e5d055 | ||
|
|
e117813578 | ||
|
|
3b5336a487 | ||
|
|
0b16ee802d | ||
|
|
64da60d480 | ||
|
|
2d6581f31f | ||
|
|
bec56d4114 | ||
|
|
37de7c96a1 | ||
|
|
6c5972aa58 | ||
|
|
55034fc23c | ||
|
|
bed877760e | ||
|
|
87e1755a4b | ||
|
|
39d414d588 | ||
|
|
00fd0ebb15 | ||
|
|
ddd420cc21 | ||
|
|
a7288cc612 | ||
|
|
fdcdb3b9d7 | ||
|
|
bd9ed7eb88 | ||
|
|
5d471b77fb | ||
|
|
61c527eb26 | ||
|
|
f5c35e8142 | ||
|
|
9adab6882d | ||
|
|
67ef912ba0 | ||
|
|
f2f5728c72 | ||
|
|
c19dac5aef | ||
|
|
480cb4fa92 |
No files matched your search
@@ -23,13 +23,27 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||
- name: actionlint handwritten workflows
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: >-
|
||||
.github/workflows/actions-security.yml,
|
||||
.github/workflows/ci.yml,
|
||||
.github/workflows/interop-netem.yml,
|
||||
.github/workflows/osv-scanner.yml,
|
||||
.github/workflows/rust-clippy.yml,
|
||||
.github/workflows/semver.yml,
|
||||
.github/workflows/supply-chain.yml
|
||||
|
||||
- name: actionlint generated release workflow
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: .github/workflows/release.yml
|
||||
shellcheck: false
|
||||
zizmor:
|
||||
name: zizmor
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,11 +56,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
persona: regular
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -63,6 +63,38 @@ jobs:
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
- name: Run clippy without default features
|
||||
run: |
|
||||
cargo clippy \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
check:
|
||||
name: cargo check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@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:
|
||||
name: cargo test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -70,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -87,6 +119,13 @@ jobs:
|
||||
--all-features \
|
||||
--locked
|
||||
|
||||
- name: Run tests without default features
|
||||
run: |
|
||||
cargo test \
|
||||
--workspace \
|
||||
--no-default-features \
|
||||
--locked
|
||||
|
||||
docs:
|
||||
name: cargo doc
|
||||
runs-on: ubuntu-latest
|
||||
@@ -97,7 +136,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -64,9 +64,9 @@ jobs:
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.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
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
@@ -180,14 +180,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
@@ -230,14 +230,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -250,14 +250,14 @@ jobs:
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2
|
||||
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
@@ -56,9 +56,24 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||
- name: actionlint handwritten workflows
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: >-
|
||||
.github/workflows/actions-security.yml,
|
||||
.github/workflows/ci.yml,
|
||||
.github/workflows/interop-netem.yml,
|
||||
.github/workflows/osv-scanner.yml,
|
||||
.github/workflows/rust-clippy.yml,
|
||||
.github/workflows/semver.yml,
|
||||
.github/workflows/supply-chain.yml
|
||||
|
||||
- name: actionlint generated release workflow
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: .github/workflows/release.yml
|
||||
shellcheck: false
|
||||
Generated
+525
-20
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -38,7 +44,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -49,14 +55,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -76,6 +82,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -130,9 +151,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -140,6 +161,20 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -147,10 +182,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
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 = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -163,7 +223,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -175,6 +235,40 @@ dependencies = [
|
||||
"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]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
@@ -199,6 +293,45 @@ dependencies = [
|
||||
"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]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -223,20 +356,50 @@ dependencies = [
|
||||
"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]]
|
||||
name = "irtt-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-stats",
|
||||
"ratatui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"libc",
|
||||
@@ -246,7 +409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-proto"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"md-5",
|
||||
@@ -256,7 +419,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-stats"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"irtt-client",
|
||||
]
|
||||
@@ -267,12 +430,57 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
@@ -283,6 +491,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -316,6 +536,35 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -335,21 +584,147 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
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 = [
|
||||
"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]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -389,9 +764,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
@@ -399,18 +774,84 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -419,3 +860,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"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
@@ -16,9 +16,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
readme = "README.md"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.2.0", path = "crates/irtt-client" }
|
||||
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" }
|
||||
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.3.0", path = "crates/irtt-client" }
|
||||
irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
@@ -10,13 +10,17 @@ It is not the upstream IRTT project. For the original implementation, protocol b
|
||||
|
||||
This repository currently provides:
|
||||
|
||||
- `irtt-cli`, a command-line IRTT-compatible client
|
||||
- `irtt-rs`, the canonical multi-applet binary
|
||||
- `irtt-cli`, a stream/text IRTT-compatible client applet
|
||||
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
|
||||
- `irtt-client`, a Rust library for running client sessions and consuming events
|
||||
- finite and continuous probe runs
|
||||
- human, simple, machine-readable, and RTT-only output modes
|
||||
- human, simple, machine-readable, and RTT-only stream output modes
|
||||
- optional local summary statistics
|
||||
|
||||
Server support is not implemented.
|
||||
The installed binary is intentionally not named `irtt` to avoid conflicting or
|
||||
confusing overlap with upstream IRTT.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -30,12 +34,24 @@ cd irtt-rs
|
||||
cargo install --path crates/irtt-cli
|
||||
```
|
||||
|
||||
The default install provides the normal client-oriented binaries, including
|
||||
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
|
||||
|
||||
```sh
|
||||
cargo install --path crates/irtt-cli --features tui
|
||||
```
|
||||
|
||||
Or run it directly without installing:
|
||||
|
||||
```sh
|
||||
cargo run -p irtt-cli -- <server>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Basic test:
|
||||
@@ -65,10 +81,22 @@ irtt-cli <server> --output machine
|
||||
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:
|
||||
|
||||
```sh
|
||||
irtt-cli --help
|
||||
irtt-tui --help
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
```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.
|
||||
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:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Command-line IRTT-compatible client"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,13 +11,34 @@ readme.workspace = true
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
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]
|
||||
default = ["stats"]
|
||||
stats = ["dep:irtt-stats"]
|
||||
default = ["client", "stats"]
|
||||
client = ["dep:irtt-client", "dep:irtt-proto"]
|
||||
stats = ["client", "dep:irtt-stats"]
|
||||
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
crossterm = { version = "0.28.1", optional = true }
|
||||
ctrlc = "3.5.2"
|
||||
irtt-client.workspace = true
|
||||
irtt-proto.workspace = true
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.29.0", optional = true }
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
pub struct SummaryFormatOptions {
|
||||
pub verbose: bool,
|
||||
pub show_running_only_note: bool,
|
||||
}
|
||||
|
||||
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, "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,
|
||||
@@ -198,10 +206,8 @@ mod tests {
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(Duration::from_micros(1200)),
|
||||
effective: Duration::from_micros(1200),
|
||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
||||
effective_signed: SignedDuration { ns: 1_200_000 },
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
@@ -232,6 +238,7 @@ mod tests {
|
||||
assert!(output.contains("10.0µs"));
|
||||
assert!(output.contains("timer error"));
|
||||
assert!(output.contains("2.0µs"));
|
||||
assert!(!output.contains("medians unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -245,10 +252,8 @@ mod tests {
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(Duration::from_micros(1200)),
|
||||
effective: Duration::from_micros(1200),
|
||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
||||
effective_signed: SignedDuration { ns: 1_200_000 },
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
@@ -259,13 +264,32 @@ mod tests {
|
||||
|
||||
let output = format_summary_with_options(
|
||||
&collector.snapshot(),
|
||||
SummaryFormatOptions { verbose: true },
|
||||
SummaryFormatOptions {
|
||||
verbose: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("raw 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]
|
||||
fn signed_stats_can_format_negative_values() {
|
||||
let stats = TimeStats {
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod args;
|
||||
pub mod run;
|
||||
mod ui;
|
||||
|
||||
pub use args::TuiArgs;
|
||||
pub use run::run_tui;
|
||||
@@ -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
+4
-1472
File diff suppressed because it is too large.
Load diff
+60
-447
@@ -1,40 +1,19 @@
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
env,
|
||||
ffi::OsString,
|
||||
process::ExitCode,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
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 {
|
||||
let args = CliArgs::parse();
|
||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||
if let Err(err) = install_signal_handler(Arc::clone(&shutdown_requested)) {
|
||||
eprintln!("irtt-rs: failed to install signal handler: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
match run(args, shutdown_requested.as_ref()) {
|
||||
match run_from_env() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("irtt-rs: {err}");
|
||||
@@ -43,437 +22,71 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mode = args.output;
|
||||
let continuous = args.is_continuous();
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
#[cfg(feature = "stats")]
|
||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
||||
let mut output = EventOutput {
|
||||
mode,
|
||||
human_options: HumanOutputOptions {
|
||||
verbose: args.verbose,
|
||||
},
|
||||
print_finite_summary: !continuous,
|
||||
out: &mut stdout,
|
||||
#[cfg(feature = "stats")]
|
||||
stats: &mut stats,
|
||||
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<OsString> = env::args_os().collect();
|
||||
let argv0 = argv
|
||||
.first()
|
||||
.and_then(|arg| arg.to_str())
|
||||
.unwrap_or("irtt-rs");
|
||||
let requested = match detect_applet_from_argv0(argv0) {
|
||||
Some(applet) => applet,
|
||||
None => default_requested_applet()?,
|
||||
};
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||
install_signal_handler(Arc::clone(&shutdown_requested))
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
|
||||
match requested {
|
||||
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Server => Err("server applet is not available in this build".into()),
|
||||
}
|
||||
}
|
||||
|
||||
let mut client = Client::connect(args.to_client_config())?;
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
fn default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
|
||||
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
|
||||
AppletAvailability::Client => Ok(RequestedApplet::Client),
|
||||
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
|
||||
AppletAvailability::Server => Ok(RequestedApplet::Server),
|
||||
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
shutdown_requested.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn should_continue_run(
|
||||
continuous: bool,
|
||||
next_send_deadline: Option<Instant>,
|
||||
#[cfg(feature = "client")]
|
||||
fn run_client_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> bool {
|
||||
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
|
||||
}
|
||||
|
||||
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
||||
!is_shutdown_requested(shutdown_requested)
|
||||
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
||||
}
|
||||
|
||||
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
||||
!continuous || interrupted
|
||||
}
|
||||
|
||||
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
||||
match outcome {
|
||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_final_replies<W: Write>(
|
||||
client: &mut Client,
|
||||
output: &mut EventOutput<'_, W>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
|
||||
loop {
|
||||
let mut printed = false;
|
||||
use clap::Parser;
|
||||
|
||||
let events = client.recv_available(RECV_BUDGET)?;
|
||||
printed |= !events.is_empty();
|
||||
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;
|
||||
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
|
||||
irtt_cli::cmd::client::run_stream(args, shutdown_requested)
|
||||
}
|
||||
|
||||
if !printed {
|
||||
thread::sleep(IDLE_SLEEP);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
#[cfg(not(feature = "client"))]
|
||||
fn run_client_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("client applet is not available; rebuild with the client feature".into())
|
||||
}
|
||||
|
||||
fn sleep_until_next_send(deadline: Option<Instant>) {
|
||||
let sleep_for = match deadline {
|
||||
Some(deadline) => deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.min(MAX_SLEEP),
|
||||
None => Duration::from_millis(1),
|
||||
};
|
||||
if !sleep_for.is_zero() {
|
||||
thread::sleep(sleep_for);
|
||||
}
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
|
||||
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
|
||||
}
|
||||
|
||||
struct EventOutput<'a, W: Write> {
|
||||
mode: irtt_cli::OutputMode,
|
||||
human_options: HumanOutputOptions,
|
||||
print_finite_summary: bool,
|
||||
out: &'a mut W,
|
||||
#[cfg(feature = "stats")]
|
||||
stats: &'a mut StatsCollector,
|
||||
}
|
||||
|
||||
impl<W: Write> EventOutput<'_, W> {
|
||||
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
|
||||
for event in events {
|
||||
self.print_event(event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
|
||||
#[cfg(feature = "stats")]
|
||||
let stats_update = self.stats.process(event);
|
||||
|
||||
#[cfg(not(feature = "stats"))]
|
||||
let stats_update = irtt_cli::HumanEventStats::default();
|
||||
|
||||
let line =
|
||||
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
|
||||
Some(format_human_event_with_options(
|
||||
event,
|
||||
Some(stats_update.into()),
|
||||
self.human_options,
|
||||
))
|
||||
} else {
|
||||
format_event(event, self.mode)
|
||||
};
|
||||
|
||||
if let Some(line) = line {
|
||||
writeln!(self.out, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_summary(&mut self) -> io::Result<()> {
|
||||
if !self.print_finite_summary || !self.mode.prints_summary() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "stats")]
|
||||
{
|
||||
write!(
|
||||
self.out,
|
||||
"{}",
|
||||
irtt_cli::summary::format_summary_with_options(
|
||||
&self.stats.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)
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "tui"))]
|
||||
fn run_tui_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -14,7 +14,7 @@ categories = ["network-programming"]
|
||||
[dependencies]
|
||||
irtt-proto.workspace = true
|
||||
libc = { version = "0.2.186", optional = true }
|
||||
socket2 = { version = "0.6.3", features = ["all"] }
|
||||
socket2 = { version = "0.6.4", features = ["all"] }
|
||||
thiserror = "2.0.18"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -32,6 +32,12 @@ use crate::{
|
||||
const MAX_OPEN_PACKET_SIZE: usize = 512;
|
||||
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)]
|
||||
pub struct Client {
|
||||
config: ClientConfig,
|
||||
@@ -45,6 +51,11 @@ pub struct 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> {
|
||||
validate_open_timeouts(&config.open_timeouts)?;
|
||||
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> {
|
||||
match self.phase {
|
||||
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> {
|
||||
let token = match self.phase {
|
||||
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> {
|
||||
let session = self.session.as_ref()?;
|
||||
if session.sending_done {
|
||||
@@ -161,14 +183,24 @@ impl Client {
|
||||
Some(session.next_send_at)
|
||||
}
|
||||
|
||||
/// Return the local timeout used to classify pending probes as lost.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
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> {
|
||||
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> {
|
||||
self.recv_once_inner(None)
|
||||
}
|
||||
@@ -315,6 +347,8 @@ impl Client {
|
||||
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> {
|
||||
let mut all_events = Vec::new();
|
||||
for _ in 0..budget.max_packets {
|
||||
@@ -327,12 +361,6 @@ impl Client {
|
||||
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.
|
||||
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.poll_timeouts_at(Instant::now())
|
||||
@@ -372,6 +400,11 @@ impl Client {
|
||||
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 {
|
||||
let Some(session) = self.session.as_ref() else {
|
||||
return matches!(
|
||||
@@ -394,24 +427,6 @@ impl Client {
|
||||
.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> {
|
||||
let negotiated = self
|
||||
.negotiated
|
||||
@@ -457,7 +472,7 @@ impl Client {
|
||||
.is_some_and(|h| sequence_is_before(wire_seq, h));
|
||||
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);
|
||||
|
||||
let mut events = Vec::new();
|
||||
@@ -494,7 +509,7 @@ impl Client {
|
||||
}
|
||||
Ok(events)
|
||||
} 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 {
|
||||
seq: wire_seq,
|
||||
remote: self.remote,
|
||||
@@ -507,7 +522,7 @@ impl Client {
|
||||
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
|
||||
let received_stats = build_received_stats(&reply);
|
||||
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
||||
update_highest_received(session, wire_seq);
|
||||
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
||||
session.completed.insert(wire_seq);
|
||||
|
||||
let mut events = vec![ClientEvent::LateReply {
|
||||
@@ -688,14 +703,10 @@ impl Client {
|
||||
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
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) {
|
||||
session.highest_received_seq = Some(session.highest_received_seq.map_or(wire_seq, |h| {
|
||||
fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
|
||||
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
|
||||
if sequence_is_after(wire_seq, h) {
|
||||
wire_seq
|
||||
} else {
|
||||
@@ -755,22 +766,14 @@ fn compute_rtt(
|
||||
|
||||
let server_processing = compute_server_processing(ts);
|
||||
|
||||
let adjusted = server_processing.and_then(|sp| raw.checked_sub(sp));
|
||||
|
||||
let effective = adjusted.unwrap_or(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),
|
||||
});
|
||||
let adjusted = server_processing
|
||||
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
|
||||
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
|
||||
|
||||
RttSample {
|
||||
raw,
|
||||
adjusted,
|
||||
effective,
|
||||
adjusted_signed,
|
||||
effective_signed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,11 +828,11 @@ fn compute_one_way(
|
||||
let c2s = server_recv_wall
|
||||
.zip(client_send_ns)
|
||||
.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
|
||||
.zip(server_send_wall)
|
||||
.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() {
|
||||
return None;
|
||||
|
||||
@@ -68,31 +68,6 @@ fn send_probe_fails_after_close() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_clears_timed_out_metadata() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().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]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
|
||||
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_accepts_udp_payload_length_boundary_values() {
|
||||
fn params_from_config_accepts_boundary_values() {
|
||||
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
||||
let config = ClientConfig {
|
||||
length,
|
||||
@@ -62,18 +62,12 @@ fn params_from_config_accepts_udp_payload_length_boundary_values() {
|
||||
i64::from(length)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_oversized_udp_payload_length() {
|
||||
let config = ClientConfig {
|
||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||
dscp: 63,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
|
||||
));
|
||||
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -86,97 +80,85 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_zero_finite_duration() {
|
||||
let config = ClientConfig {
|
||||
fn params_from_config_rejects_invalid_values() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let cases = [
|
||||
(
|
||||
"oversized UDP payload",
|
||||
ClientConfig {
|
||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"packet length",
|
||||
),
|
||||
(
|
||||
"zero finite duration",
|
||||
ClientConfig {
|
||||
duration: Some(Duration::ZERO),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
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 {
|
||||
},
|
||||
"duration must be greater than zero; use None for continuous mode",
|
||||
),
|
||||
(
|
||||
"zero interval",
|
||||
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 config = ClientConfig {
|
||||
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
|
||||
},
|
||||
"interval must be greater than zero",
|
||||
),
|
||||
(
|
||||
"duration nanosecond overflow",
|
||||
ClientConfig {
|
||||
duration: Some(too_large),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason })
|
||||
if reason == "duration is too large to encode as nanoseconds"
|
||||
));
|
||||
|
||||
let config = ClientConfig {
|
||||
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
|
||||
},
|
||||
"duration is too large to encode as nanoseconds",
|
||||
),
|
||||
(
|
||||
"interval nanosecond overflow",
|
||||
ClientConfig {
|
||||
interval: too_large,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason })
|
||||
if reason == "interval is too large to encode as nanoseconds"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_accepts_max_dscp_codepoint() {
|
||||
let config = ClientConfig {
|
||||
dscp: 63,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_invalid_dscp_codepoint() {
|
||||
let config = ClientConfig {
|
||||
},
|
||||
"interval is too large to encode as nanoseconds",
|
||||
),
|
||||
(
|
||||
"invalid DSCP codepoint",
|
||||
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 {
|
||||
},
|
||||
"dscp",
|
||||
),
|
||||
(
|
||||
"empty server fill",
|
||||
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",
|
||||
),
|
||||
(
|
||||
"oversized server fill",
|
||||
ClientConfig {
|
||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
},
|
||||
"server_fill",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, config, expected_reason) in cases {
|
||||
assert!(
|
||||
matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
||||
),
|
||||
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_test_open_close_succeeds_on_open_reply_close() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
|
||||
assert!(matches!(client.close(), Err(ClientError::NotOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_success_validates_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn address_resolution_connects_to_local_fake_server() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.socket.send(b"ping").unwrap();
|
||||
assert_eq!(server.rx.recv().unwrap(), b"ping");
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_open_handshake() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
@@ -20,7 +9,6 @@ fn successful_open_handshake() {
|
||||
|
||||
let negotiated = assert_open_started(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -69,27 +57,6 @@ fn open_timeout_after_all_timeouts() {
|
||||
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]
|
||||
fn protocol_version_mismatch_fails() {
|
||||
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(¶ms);
|
||||
|
||||
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(¶ms);
|
||||
|
||||
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, ¶ms, 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, ¶ms, &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, ¶ms, 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, ¶ms, &TimestampFields::default(), None);
|
||||
reply.truncate(echo_packet_len(false, ¶ms) - 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, ¶ms, 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, ¶ms, &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, ¶ms, 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, ¶ms, &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, ¶ms)
|
||||
));
|
||||
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, ¶ms));
|
||||
}
|
||||
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, ¶ms, 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, ¶ms, &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, ¶ms, 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], ¶ms, &ts, None);
|
||||
socket.send_to(&reply1, peer).unwrap();
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let reply0 = echo_reply_packet(TOKEN, seqs[0], ¶ms, &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(¶ms);
|
||||
|
||||
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, ¶ms));
|
||||
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, ¶ms));
|
||||
}
|
||||
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, ¶ms, 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, ¶ms, &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(¶ms);
|
||||
|
||||
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, ¶ms, 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, ¶ms, &ts, None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
let losses = client.poll_timeouts().unwrap();
|
||||
assert!(matches!(&losses[0], ClientEvent::EchoLoss { seq: 0, .. }));
|
||||
|
||||
let late = client.recv_once().unwrap();
|
||||
match &late[0] {
|
||||
ClientEvent::LateReply {
|
||||
seq,
|
||||
sent_at,
|
||||
rtt,
|
||||
server_timing,
|
||||
one_way,
|
||||
bytes,
|
||||
packet_meta,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*seq, 0);
|
||||
assert!(sent_at.is_some());
|
||||
assert!(rtt.is_some());
|
||||
assert!(server_timing.is_some());
|
||||
assert!(one_way.is_some());
|
||||
assert_eq!(*bytes, echo_packet_len(false, &default_params()));
|
||||
let _ = packet_meta;
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
assert_packet_meta_unavailable(packet_meta);
|
||||
}
|
||||
other => panic!("expected stats-eligible LateReply, got {other:?}"),
|
||||
}
|
||||
let duplicate = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
&duplicate[0],
|
||||
ClientEvent::DuplicateReply {
|
||||
seq: 0,
|
||||
bytes,
|
||||
..
|
||||
} if *bytes == echo_packet_len(false, &default_params())
|
||||
));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_full_does_not_send_packet() {
|
||||
let params = Params {
|
||||
duration_ns: 60_000_000_000,
|
||||
..default_params()
|
||||
};
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(60)),
|
||||
max_pending_probes: 2,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
client.send_probe().unwrap();
|
||||
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let before_count: Vec<_> = server.rx.try_iter().collect();
|
||||
let echo_before: Vec<_> = before_count
|
||||
.iter()
|
||||
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
|
||||
.collect();
|
||||
assert_eq!(echo_before.len(), 2);
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
||||
));
|
||||
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let after: Vec<_> = server.rx.try_iter().collect();
|
||||
let echo_after: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
echo_after.len(),
|
||||
0,
|
||||
"no packet should be sent when pending is full"
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_processing_greater_than_raw_does_not_underflow() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(1),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields {
|
||||
recv_mono: Some(0),
|
||||
send_mono: Some(1_000_000_000),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
|
||||
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(42),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields::default(),
|
||||
);
|
||||
|
||||
assert_eq!(rtt.adjusted, None);
|
||||
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
|
||||
let overflow = max + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
|
||||
assert_eq!(unix_epoch_ns_i64(overflow), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let overflow_wall =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: overflow_wall,
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: overflow_wall + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(i64::MAX),
|
||||
send_wall: Some(i64::MAX),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(compute_one_way(&sent_at, &received_at, &ts), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_returns_available_direction_samples() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_client_to_server_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 - 5_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_server_to_client_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 45_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
probe::{CompletedSet, PendingMap, TimedOutMap},
|
||||
session::ActiveSession,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||
@@ -24,21 +18,10 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||
|
||||
#[test]
|
||||
fn highest_received_seq_updates_across_wrap() {
|
||||
let mut session = ActiveSession {
|
||||
next_wire_seq: 0,
|
||||
highest_received_seq: Some(u32::MAX),
|
||||
packets_sent: 0,
|
||||
start_mono: Instant::now(),
|
||||
end_mono: None,
|
||||
next_send_at: Instant::now(),
|
||||
pending: PendingMap::new(8),
|
||||
timed_out: TimedOutMap::new(8),
|
||||
completed: CompletedSet::new(8),
|
||||
sending_done: false,
|
||||
};
|
||||
let mut highest = Some(u32::MAX);
|
||||
|
||||
update_highest_received(&mut session, 0);
|
||||
assert_eq!(session.highest_received_seq, Some(0));
|
||||
update_highest_received(&mut session, u32::MAX);
|
||||
assert_eq!(session.highest_received_seq, Some(0));
|
||||
update_highest_received(&mut highest, 0);
|
||||
assert_eq!(highest, Some(0));
|
||||
update_highest_received(&mut highest, u32::MAX);
|
||||
assert_eq!(highest, Some(0));
|
||||
}
|
||||
@@ -1,12 +1,31 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
||||
/// bytes.
|
||||
///
|
||||
/// This is the maximum encoded server-fill string accepted by this client and
|
||||
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
|
||||
/// before opening a session.
|
||||
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
||||
/// Largest valid DSCP codepoint accepted by client configuration.
|
||||
///
|
||||
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
|
||||
/// socket traffic-class field when configuring packet priority.
|
||||
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
||||
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
|
||||
///
|
||||
/// This is the public user-configuration bound for
|
||||
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
|
||||
/// TTL and hop-limit settings are configured as `1..=255`.
|
||||
pub const MAX_TTL: u32 = 255;
|
||||
/// Largest UDP payload length accepted by client configuration, in bytes.
|
||||
///
|
||||
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
|
||||
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
|
||||
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
||||
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
|
||||
@@ -19,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_MAX_PENDING: usize = 4096;
|
||||
|
||||
/// Configuration for opening and running an IRTT client session.
|
||||
///
|
||||
/// This type describes both the protocol parameters sent in the IRTT open
|
||||
/// request and the local client behavior used to drive the UDP socket. Values
|
||||
/// that are negotiated by the server are available after opening the session
|
||||
/// through [`NegotiatedParams`](crate::NegotiatedParams).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
/// Remote server name or address.
|
||||
///
|
||||
/// If no port is present, the default IRTT port 2112 is used. IPv6
|
||||
/// literals may be supplied either bracketed or unbracketed when the
|
||||
/// default port should be used.
|
||||
pub server_addr: String,
|
||||
/// Requested run duration.
|
||||
///
|
||||
/// `Some(duration)` requests a finite test and must be greater than zero.
|
||||
/// `None` requests continuous mode and is encoded on the wire as a zero
|
||||
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
|
||||
/// without sending probes.
|
||||
pub duration: Option<Duration>,
|
||||
/// Requested spacing between probe sends.
|
||||
///
|
||||
/// The interval is encoded as nanoseconds in the open request and must be
|
||||
/// greater than zero. The server may return a different interval depending
|
||||
/// on the negotiated policy and server restrictions.
|
||||
pub interval: Duration,
|
||||
/// Requested echo packet payload length, in bytes.
|
||||
///
|
||||
/// The value must fit within the UDP payload limit after protocol overhead.
|
||||
/// A server may reduce the requested length during negotiation.
|
||||
pub length: u32,
|
||||
/// Requested server receive-statistics fields in echo replies.
|
||||
pub received_stats: ReceivedStats,
|
||||
/// Requested timestamp placement in echo replies.
|
||||
pub stamp_at: StampAt,
|
||||
/// Requested server clock sources for timestamp fields.
|
||||
pub clock: Clock,
|
||||
/// Requested DSCP codepoint.
|
||||
///
|
||||
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
||||
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
||||
pub dscp: u8,
|
||||
/// Optional HMAC key used to authenticate IRTT packets.
|
||||
///
|
||||
/// When present, open, echo, and close packets are encoded and decoded with
|
||||
/// HMAC authentication. The peer must be configured with the same key.
|
||||
pub hmac_key: Option<Vec<u8>>,
|
||||
/// Optional server payload fill request.
|
||||
///
|
||||
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
||||
/// a non-empty server fill mode/value and must not exceed
|
||||
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
|
||||
pub server_fill: Option<String>,
|
||||
/// Per-attempt receive timeouts used while opening the session.
|
||||
///
|
||||
/// The client sends an open request for each entry until a valid open reply
|
||||
/// is received. The list must not be empty, and each timeout must be at
|
||||
/// least 200 ms.
|
||||
pub open_timeouts: Vec<Duration>,
|
||||
/// Whether opening the session should start a probe test or perform a
|
||||
/// negotiation-only no-test exchange.
|
||||
pub run_mode: RunMode,
|
||||
/// Policy for server changes to negotiable protocol parameters.
|
||||
pub negotiation_policy: NegotiationPolicy,
|
||||
/// Local UDP socket configuration.
|
||||
pub socket_config: SocketConfig,
|
||||
/// Time after sending a probe before the client reports it as lost.
|
||||
///
|
||||
/// This timeout is local client behavior; it is not negotiated with the
|
||||
/// server. It must be greater than zero.
|
||||
pub probe_timeout: Duration,
|
||||
/// Maximum number of probes tracked as pending/timed-out/completed.
|
||||
///
|
||||
/// This bounds memory used for reply classification and must be greater
|
||||
/// than zero. A very small value can reject sends when replies are still
|
||||
/// outstanding.
|
||||
pub max_pending_probes: usize,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
pub struct SocketConfig {
|
||||
/// Local address to bind before connecting the UDP socket.
|
||||
///
|
||||
/// `None` binds to an unspecified address with an ephemeral port matching
|
||||
/// the selected remote address family.
|
||||
pub bind_addr: Option<SocketAddr>,
|
||||
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
||||
///
|
||||
/// `None` leaves the platform default unchanged. Values must fit in the
|
||||
/// platform socket option range; [`MAX_TTL`] is the public configuration
|
||||
/// bound used by this crate.
|
||||
pub ttl: Option<u32>,
|
||||
/// Restrict name resolution to IPv4 addresses.
|
||||
pub ipv4_only: bool,
|
||||
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
|
||||
/// sockets.
|
||||
pub ipv6_only: bool,
|
||||
/// Socket read timeout used after the session is open.
|
||||
///
|
||||
/// `None` leaves reads blocking for APIs that perform a single receive.
|
||||
/// Managed sessions may replace `None` or long timeouts with a short
|
||||
/// timeout so cooperative cancellation can be observed promptly.
|
||||
pub recv_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
/// How strictly to handle server-side negotiation restrictions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NegotiationPolicy {
|
||||
/// Reject any negotiated parameter that is more restrictive or different
|
||||
/// than requested.
|
||||
Strict,
|
||||
/// Accept documented server restrictions and report them in
|
||||
/// [`NegotiatedParams`](crate::NegotiatedParams).
|
||||
Loose,
|
||||
}
|
||||
|
||||
/// Mode requested during the IRTT open exchange.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunMode {
|
||||
/// Open a session and send echo probes for the negotiated run duration.
|
||||
Normal,
|
||||
/// Complete negotiation without running the echo probe test.
|
||||
NoTest,
|
||||
}
|
||||
|
||||
/// Bound for a single receive-drain operation.
|
||||
///
|
||||
/// This is used by lower-level callers that drive [`Client`](crate::Client)
|
||||
/// directly and want to cap how many datagrams are processed before returning
|
||||
/// to their own event loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RecvBudget {
|
||||
/// Maximum number of datagrams to process before returning.
|
||||
pub max_packets: usize,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,66 +2,176 @@ use std::{io, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Failure reported while configuring, opening, running, receiving, closing, or
|
||||
/// joining an IRTT client session.
|
||||
///
|
||||
/// Configuration validation errors usually require changing
|
||||
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
|
||||
/// Network, protocol, and runtime errors may be transient, but can also reflect
|
||||
/// peer incompatibility or an invalid session state.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ClientError {
|
||||
/// The configured server address could not be resolved to a socket address.
|
||||
///
|
||||
/// Check the host, port, address-family restrictions, and name service
|
||||
/// configuration before retrying.
|
||||
#[error("failed to resolve server address {addr:?}")]
|
||||
Resolve { addr: String },
|
||||
/// A UDP socket operation failed while binding, connecting, sending, or
|
||||
/// receiving.
|
||||
///
|
||||
/// This can be a transient network or OS error, but may also reflect local
|
||||
/// permissions, routing, or socket configuration.
|
||||
#[error("socket error: {0}")]
|
||||
Socket(#[from] io::Error),
|
||||
/// Applying or reading a socket option failed.
|
||||
///
|
||||
/// This is usually caused by an unsupported option, an invalid local value,
|
||||
/// or a platform-specific socket limitation. Changing socket configuration
|
||||
/// is more likely to help than retrying unchanged.
|
||||
#[error("socket option error while trying to {operation} for {remote}: {source}")]
|
||||
SocketOption {
|
||||
operation: &'static str,
|
||||
remote: std::net::SocketAddr,
|
||||
source: io::Error,
|
||||
},
|
||||
/// Restoring the configured read timeout after open negotiation failed.
|
||||
///
|
||||
/// The session may have opened successfully, but the socket could not be
|
||||
/// returned to the caller's requested receive-timeout behavior.
|
||||
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
|
||||
ReadTimeoutRestore { source: io::Error },
|
||||
/// A packet failed IRTT protocol parsing or authentication.
|
||||
///
|
||||
/// Likely causes include malformed input, unsupported protocol data, or an
|
||||
/// HMAC mismatch between client and server.
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(#[from] irtt_proto::ProtoError),
|
||||
/// No valid open reply was received before all configured open attempts
|
||||
/// timed out.
|
||||
///
|
||||
/// Retrying can succeed when the server or network is temporarily
|
||||
/// unavailable. Persistent failures usually require changing the address,
|
||||
/// authentication key, server configuration, or timeout settings.
|
||||
#[error("all open requests timed out")]
|
||||
OpenTimeout,
|
||||
/// An open-attempt timeout is below the client's supported minimum.
|
||||
///
|
||||
/// Increase the configured timeout before retrying.
|
||||
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
|
||||
OpenTimeoutTooSmall {
|
||||
timeout: Duration,
|
||||
minimum: Duration,
|
||||
},
|
||||
/// The configured open-attempt timeout list is empty.
|
||||
///
|
||||
/// Provide at least one timeout before opening the session.
|
||||
#[error("open_timeouts must not be empty")]
|
||||
NoOpenTimeouts,
|
||||
/// The server explicitly rejected the open request.
|
||||
///
|
||||
/// Retrying unchanged is unlikely to help unless the server configuration or
|
||||
/// load has changed.
|
||||
#[error("server rejected the open request")]
|
||||
ServerRejected,
|
||||
/// A no-test open request received a reply that did not have no-test close
|
||||
/// semantics.
|
||||
///
|
||||
/// This indicates an incompatible or unexpected peer response.
|
||||
#[error("unexpected no-test open reply")]
|
||||
UnexpectedNoTestReply,
|
||||
/// A no-test open reply included a connection token.
|
||||
///
|
||||
/// No-test negotiation must not establish a probe session, so a non-zero
|
||||
/// token indicates an incompatible or unexpected peer response.
|
||||
#[error("no-test reply included a non-zero connection token: {token}")]
|
||||
NonZeroNoTestToken { token: u64 },
|
||||
/// The peer replied with a protocol version different from the requested
|
||||
/// version.
|
||||
///
|
||||
/// This is a protocol compatibility error and requires compatible client
|
||||
/// and server versions.
|
||||
#[error("protocol version mismatch: requested {requested}, received {received}")]
|
||||
ProtocolVersionMismatch { requested: i64, received: i64 },
|
||||
/// The server returned a zero connection token for a normal test session.
|
||||
///
|
||||
/// A normal session requires a non-zero token to correlate echo traffic, so
|
||||
/// this indicates an invalid peer response.
|
||||
#[error("server returned a zero connection token")]
|
||||
ZeroToken,
|
||||
/// Negotiated parameters were rejected by the configured negotiation policy.
|
||||
///
|
||||
/// Use a less restrictive configuration or
|
||||
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
|
||||
/// caller accepts documented server restrictions.
|
||||
#[error("strict negotiation rejected changed params: {reason}")]
|
||||
NegotiationRejected { reason: String },
|
||||
/// The requested operation requires an open session, but the client is only
|
||||
/// connected.
|
||||
#[error("client is not open")]
|
||||
NotOpen,
|
||||
/// The session is already open.
|
||||
///
|
||||
/// Continue running, receiving, or closing the existing session instead of
|
||||
/// opening it again.
|
||||
#[error("client session is already open")]
|
||||
AlreadyOpen,
|
||||
/// The no-test session has already completed.
|
||||
///
|
||||
/// Create a new client session to perform another negotiation.
|
||||
#[error("client session is already completed")]
|
||||
AlreadyCompleted,
|
||||
/// The session is already closed.
|
||||
///
|
||||
/// Create a new client session before opening or running another test.
|
||||
#[error("client session is already closed")]
|
||||
AlreadyClosed,
|
||||
/// A duration could not be represented as nanoseconds for scheduling or
|
||||
/// protocol encoding.
|
||||
///
|
||||
/// Use smaller configured or negotiated durations.
|
||||
#[error("duration is too large to encode as nanoseconds")]
|
||||
DurationOverflow,
|
||||
/// A client-maintained counter would overflow.
|
||||
///
|
||||
/// This is a runtime limit rather than a network failure. Start a new
|
||||
/// session if the test duration or send volume reaches this limit.
|
||||
#[error("counter {counter} overflowed")]
|
||||
CounterOverflow { counter: &'static str },
|
||||
/// Sending another probe would exceed the configured pending-probe limit.
|
||||
///
|
||||
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
|
||||
/// reduce send rate, or receive replies and timeouts more often before
|
||||
/// sending again.
|
||||
#[error("pending probe limit exceeded ({limit})")]
|
||||
PendingLimitExceeded { limit: usize },
|
||||
/// Client configuration failed validation before the requested operation.
|
||||
///
|
||||
/// The reported reason names a value that must be changed before retrying.
|
||||
#[error("invalid configuration: {reason}")]
|
||||
InvalidConfig { reason: String },
|
||||
/// A managed-session worker thread panicked before producing an outcome.
|
||||
///
|
||||
/// This is a runtime failure from joining the managed session, not a
|
||||
/// protocol response from the server.
|
||||
#[error("managed client worker thread panicked")]
|
||||
WorkerPanicked,
|
||||
}
|
||||
|
||||
/// Failure reported while setting up or receiving from an event subscription.
|
||||
///
|
||||
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
|
||||
/// Once a subscription exists, this error type reports delivery failures observed
|
||||
/// by [`EventSubscription`](crate::EventSubscription).
|
||||
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EventSubscriptionError {
|
||||
/// The subscription can no longer receive new events.
|
||||
///
|
||||
/// Already queued events are delivered first. After the queue is drained,
|
||||
/// `recv` and `try_recv` return this error when the hub disconnects the
|
||||
/// subscriber, the hub is shut down, or the subscription is disconnected by
|
||||
/// its overflow policy.
|
||||
#[error("event subscription is disconnected")]
|
||||
Disconnected,
|
||||
}
|
||||
+229
-23
@@ -5,139 +5,360 @@ use std::{
|
||||
|
||||
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
||||
|
||||
/// Result of the IRTT open exchange.
|
||||
///
|
||||
/// The lower-level [`Client`](crate::Client) API returns this before any echo
|
||||
/// probes are driven. Managed sessions publish the contained lifecycle event to
|
||||
/// subscribers immediately after a successful open.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OpenOutcome {
|
||||
/// A normal probe session was opened.
|
||||
Started {
|
||||
/// Resolved remote socket address used by the UDP socket.
|
||||
remote: SocketAddr,
|
||||
/// Session token assigned by the server and used on echo/close packets.
|
||||
token: u64,
|
||||
/// Parameters returned by the server after negotiation.
|
||||
negotiated: NegotiatedParams,
|
||||
/// Lifecycle event corresponding to this outcome.
|
||||
event: ClientEvent,
|
||||
},
|
||||
/// A no-test open exchange completed without starting a probe session.
|
||||
NoTestCompleted {
|
||||
/// Resolved remote socket address used by the UDP socket.
|
||||
remote: SocketAddr,
|
||||
/// Parameters returned by the server after negotiation.
|
||||
negotiated: NegotiatedParams,
|
||||
/// Lifecycle event corresponding to this outcome.
|
||||
event: ClientEvent,
|
||||
},
|
||||
}
|
||||
|
||||
/// Event emitted by an IRTT client session.
|
||||
///
|
||||
/// Events form the public lifecycle and measurement stream for callers. UIs
|
||||
/// usually display lifecycle, warning, and per-packet events directly; summary
|
||||
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
|
||||
/// `LateReply`, and `DuplicateReply` events or pass the stream to
|
||||
/// `irtt-stats`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientEvent {
|
||||
/// A normal session has opened and is ready to send echo probes.
|
||||
SessionStarted {
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Session token assigned by the server.
|
||||
token: u64,
|
||||
/// Negotiated protocol parameters for this session.
|
||||
negotiated: NegotiatedParams,
|
||||
/// Client timestamp when the open reply was accepted.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
/// A negotiation-only no-test exchange completed.
|
||||
///
|
||||
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
|
||||
/// expected for this open outcome.
|
||||
NoTestCompleted {
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Negotiated protocol parameters returned by the server.
|
||||
negotiated: NegotiatedParams,
|
||||
/// Client timestamp when the open reply was accepted.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
/// The client sent a close request and considers the session closed.
|
||||
///
|
||||
/// This is a local lifecycle event, not an acknowledgement from the server.
|
||||
/// Managed sessions disconnect event subscribers after the worker exits,
|
||||
/// while leaving already queued events available to drain.
|
||||
SessionClosed {
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Session token that was closed.
|
||||
token: u64,
|
||||
/// Client timestamp when the close request was sent.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
|
||||
/// An echo request was sent.
|
||||
///
|
||||
/// This records local scheduling and send-call timing. It does not imply a
|
||||
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
|
||||
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
|
||||
/// packet lifecycle state.
|
||||
EchoSent {
|
||||
/// Wire sequence number sent in the echo request.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Monotonic deadline at which the probe was scheduled to be sent.
|
||||
scheduled_at: Instant,
|
||||
/// Client wall/monotonic timestamp captured for the send.
|
||||
sent_at: ClientTimestamp,
|
||||
/// Number of bytes passed to the UDP socket.
|
||||
bytes: usize,
|
||||
/// Elapsed time spent in the socket send call.
|
||||
send_call: Duration,
|
||||
/// Absolute difference between the scheduled send time and actual send
|
||||
/// timestamp.
|
||||
timer_error: Duration,
|
||||
},
|
||||
|
||||
/// First in-window reply for a pending echo request.
|
||||
///
|
||||
/// This is the primary successful measurement event. RTT and optional
|
||||
/// server timing/statistics fields should generally be aggregated from this
|
||||
/// variant.
|
||||
EchoReply {
|
||||
/// Wire sequence number from the echo reply.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Client timestamp recorded when the matching request was sent.
|
||||
sent_at: ClientTimestamp,
|
||||
/// Client timestamp recorded when the reply was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// Round-trip timing sample for this reply.
|
||||
rtt: RttSample,
|
||||
/// Optional server-reported timestamps and processing time.
|
||||
server_timing: Option<ServerTiming>,
|
||||
/// Optional one-way delay values derived from client/server wall clocks.
|
||||
one_way: Option<OneWayDelaySample>,
|
||||
/// Optional server-reported received-statistics sample.
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||
packet_meta: PacketMeta,
|
||||
},
|
||||
|
||||
/// A sent echo request exceeded the local probe timeout before a reply was
|
||||
/// matched.
|
||||
///
|
||||
/// A later datagram for the same sequence may still arrive and will be
|
||||
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
|
||||
EchoLoss {
|
||||
/// Wire sequence number that timed out.
|
||||
seq: u32,
|
||||
/// Client timestamp recorded when the request was sent.
|
||||
sent_at: ClientTimestamp,
|
||||
/// Monotonic deadline at which the probe was declared lost.
|
||||
timeout_at: std::time::Instant,
|
||||
},
|
||||
|
||||
/// Additional reply for a sequence that was already completed.
|
||||
///
|
||||
/// Duplicates are not primary RTT samples. They are useful for diagnostics
|
||||
/// and packet counters, but should not be aggregated as successful first
|
||||
/// replies.
|
||||
DuplicateReply {
|
||||
/// Wire sequence number from the duplicate reply.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Client timestamp recorded when the duplicate was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
},
|
||||
|
||||
/// Reply for a sequence that was no longer pending.
|
||||
///
|
||||
/// This commonly means the probe had already been reported as lost, or the
|
||||
/// reply arrived out of the client's current tracking window. When the
|
||||
/// original send metadata is still retained, RTT and one-way timing are
|
||||
/// included; otherwise those fields are `None`.
|
||||
LateReply {
|
||||
/// Wire sequence number from the late reply.
|
||||
seq: u32,
|
||||
/// Highest sequence number accepted as an in-window first reply so far.
|
||||
highest_seen: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Original client send timestamp, when still retained.
|
||||
sent_at: Option<ClientTimestamp>,
|
||||
/// Client timestamp recorded when the late reply was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// RTT sample computed when the original send timestamp is available.
|
||||
rtt: Option<RttSample>,
|
||||
/// Optional server-reported timestamps and processing time.
|
||||
server_timing: Option<ServerTiming>,
|
||||
/// Optional one-way delay values derived from client/server wall clocks.
|
||||
one_way: Option<OneWayDelaySample>,
|
||||
/// Optional server-reported received-statistics sample.
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||
packet_meta: PacketMeta,
|
||||
},
|
||||
|
||||
/// Non-fatal condition observed while receiving or classifying packets.
|
||||
///
|
||||
/// Warning events are diagnostic. They do not close the session by
|
||||
/// themselves and are not measurement samples.
|
||||
Warning {
|
||||
/// Machine-readable warning category.
|
||||
kind: WarningKind,
|
||||
/// Human-readable diagnostic text.
|
||||
message: String,
|
||||
/// Client timestamp when the warning was emitted.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
}
|
||||
|
||||
/// Category for a non-fatal [`ClientEvent::Warning`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WarningKind {
|
||||
/// A received datagram could not be decoded as a relevant IRTT packet.
|
||||
MalformedOrUnrelatedPacket,
|
||||
/// A decoded packet used a token that does not match the open session.
|
||||
WrongToken,
|
||||
/// A decoded reply could not be matched to tracked probe state.
|
||||
UntrackedReply,
|
||||
}
|
||||
|
||||
/// Round-trip timing for one echo reply.
|
||||
///
|
||||
/// `raw` is the client-observed elapsed time from sending the echo request to
|
||||
/// receiving the reply and is therefore non-negative. When the server reports
|
||||
/// enough monotonic timing to compute processing time, `adjusted` subtracts
|
||||
/// that server processing from `raw`. `effective` is the value used by reporting
|
||||
/// and statistics: adjusted when available, otherwise raw converted to a signed
|
||||
/// value.
|
||||
///
|
||||
/// Adjusted and effective RTT values are signed. They may be negative if the
|
||||
/// reported server processing time is greater than the raw client-observed RTT.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RttSample {
|
||||
/// Client-observed RTT from send to receive.
|
||||
pub raw: Duration,
|
||||
pub adjusted: Option<Duration>,
|
||||
pub effective: Duration,
|
||||
pub adjusted_signed: Option<SignedDuration>,
|
||||
pub effective_signed: SignedDuration,
|
||||
/// Signed RTT adjusted for server processing when server processing is available.
|
||||
pub adjusted: Option<SignedDuration>,
|
||||
/// Signed effective RTT used by stats and CLI reporting.
|
||||
///
|
||||
/// This is `adjusted` when server processing is available, otherwise `raw`
|
||||
/// converted to a signed duration.
|
||||
pub effective: SignedDuration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Signed nanosecond-backed duration used for derived timing values.
|
||||
///
|
||||
/// This type preserves negative values that can arise after subtracting server
|
||||
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||
/// which is non-negative and represented by [`Duration`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SignedDuration {
|
||||
pub ns: i128,
|
||||
ns: i128,
|
||||
}
|
||||
|
||||
impl SignedDuration {
|
||||
/// Construct a signed duration from nanoseconds.
|
||||
pub const fn from_nanos(ns: i128) -> Self {
|
||||
Self { ns }
|
||||
}
|
||||
|
||||
/// Convert a non-negative [`Duration`] into a signed duration.
|
||||
///
|
||||
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
|
||||
pub fn from_duration(duration: Duration) -> Self {
|
||||
Self {
|
||||
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the duration in nanoseconds.
|
||||
pub const fn as_nanos(self) -> i128 {
|
||||
self.ns
|
||||
}
|
||||
|
||||
/// Return the duration in whole microseconds, truncated toward zero.
|
||||
pub const fn as_micros(self) -> i128 {
|
||||
self.ns / 1_000
|
||||
}
|
||||
|
||||
/// Return the duration in whole milliseconds, truncated toward zero.
|
||||
pub const fn as_millis(self) -> i128 {
|
||||
self.ns / 1_000_000
|
||||
}
|
||||
|
||||
/// Return whether this duration is less than zero.
|
||||
pub const fn is_negative(self) -> bool {
|
||||
self.ns < 0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Duration> for SignedDuration {
|
||||
fn from(value: Duration) -> Self {
|
||||
Self::from_duration(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-reported timing fields decoded from an echo reply.
|
||||
///
|
||||
/// These values are optional because the negotiated timestamp mode, clock mode,
|
||||
/// server implementation, or packet contents may omit individual fields.
|
||||
/// Timestamp values are nanoseconds in the server's reported wall-clock or
|
||||
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
|
||||
/// reported a zero timestamp.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ServerTiming {
|
||||
/// Server wall-clock receive timestamp, in nanoseconds.
|
||||
pub receive_wall_ns: Option<i64>,
|
||||
/// Server monotonic receive timestamp, in nanoseconds.
|
||||
pub receive_mono_ns: Option<i64>,
|
||||
/// Server wall-clock send timestamp, in nanoseconds.
|
||||
pub send_wall_ns: Option<i64>,
|
||||
/// Server monotonic send timestamp, in nanoseconds.
|
||||
pub send_mono_ns: Option<i64>,
|
||||
/// Server wall-clock midpoint timestamp, in nanoseconds.
|
||||
pub midpoint_wall_ns: Option<i64>,
|
||||
/// Server monotonic midpoint timestamp, in nanoseconds.
|
||||
pub midpoint_mono_ns: Option<i64>,
|
||||
/// Server processing time computed from monotonic receive/send timestamps.
|
||||
///
|
||||
/// `None` means the required monotonic timestamps were unavailable or
|
||||
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
|
||||
/// was available and zero.
|
||||
pub processing: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Directional one-way delay samples computed from wall-clock timestamps.
|
||||
///
|
||||
/// Each directional field is an `Option<SignedDuration>`. `None` means the
|
||||
/// required client or server wall-clock timestamp was unavailable, or the
|
||||
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
|
||||
/// computed from available timestamps.
|
||||
///
|
||||
/// Negative computed values are preserved as `Some` instead of being clamped to
|
||||
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
|
||||
/// between the client and server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OneWayDelaySample {
|
||||
pub client_to_server: Option<Duration>,
|
||||
pub server_to_client: Option<Duration>,
|
||||
/// Client-to-server delay.
|
||||
///
|
||||
/// This is computed from the client send wall time and the server receive
|
||||
/// wall time when both are available.
|
||||
pub client_to_server: Option<SignedDuration>,
|
||||
/// Server-to-client delay.
|
||||
///
|
||||
/// This is computed from the server send wall time and the client receive
|
||||
/// wall time when both are available.
|
||||
pub server_to_client: Option<SignedDuration>,
|
||||
}
|
||||
|
||||
/// Server-reported receive counters decoded from an echo reply.
|
||||
///
|
||||
/// These fields are optional because the negotiated received-statistics mode or
|
||||
/// server implementation may omit them. `None` means unavailable; `Some(0)`
|
||||
/// means the server reported the value and it was zero.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReceivedStatsSample {
|
||||
/// Server-reported count of received packets, when available.
|
||||
pub count: Option<u32>,
|
||||
/// Server-reported receive window value, when available.
|
||||
pub window: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -152,18 +373,3 @@ pub struct PacketMeta {
|
||||
pub ecn: Option<u8>,
|
||||
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PacketMeta;
|
||||
|
||||
#[test]
|
||||
fn packet_meta_default_is_unavailable() {
|
||||
let meta = PacketMeta::default();
|
||||
|
||||
assert_eq!(meta.traffic_class, None);
|
||||
assert_eq!(meta.dscp, None);
|
||||
assert_eq!(meta.ecn, None);
|
||||
assert_eq!(meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,50 @@
|
||||
//! Reusable client/session/event layer for IRTT-compatible probes.
|
||||
//!
|
||||
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
|
||||
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
|
||||
//! values for callers to consume directly or aggregate with `irtt-stats`.
|
||||
//!
|
||||
//! Timing values intentionally preserve signed measurement semantics. When
|
||||
//! server timing is available, [`RttSample::effective`] is adjusted for server
|
||||
//! processing and can be negative if the reported server processing time exceeds
|
||||
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
|
||||
//! also signed when the required wall-clock timestamps are available; negative
|
||||
//! values usually indicate client/server clock skew.
|
||||
//!
|
||||
//! A managed session can drive the socket loop on a worker thread and publish
|
||||
//! events through a subscription:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::time::Duration;
|
||||
//!
|
||||
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig};
|
||||
//!
|
||||
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let config = ClientConfig {
|
||||
//! server_addr: "127.0.0.1:2112".to_owned(),
|
||||
//! duration: Some(Duration::from_secs(10)),
|
||||
//! interval: Duration::from_secs(1),
|
||||
//! ..ClientConfig::default()
|
||||
//! };
|
||||
//!
|
||||
//! let (session, events) =
|
||||
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
|
||||
//!
|
||||
//! while let Ok(event) = events.recv() {
|
||||
//! match event {
|
||||
//! ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
|
||||
//! }
|
||||
//! ClientEvent::SessionClosed { .. } => break,
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! let _outcome = session.join()?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
#![cfg_attr(
|
||||
not(all(target_os = "linux", feature = "ancillary")),
|
||||
forbid(unsafe_code)
|
||||
|
||||
@@ -3,20 +3,31 @@ use std::sync::{
|
||||
Arc,
|
||||
};
|
||||
|
||||
/// Cloneable cooperative cancellation flag.
|
||||
///
|
||||
/// Managed sessions use this internally to ask their worker loop to stop. It is
|
||||
/// exported for callers that want the same simple cancellation primitive in
|
||||
/// code built around `irtt-client`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CancellationToken {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CancellationToken {
|
||||
/// Create a token in the non-cancelled state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Mark the token as cancelled.
|
||||
///
|
||||
/// Cancellation is idempotent and is visible through all clones of the
|
||||
/// token.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Return whether cancellation has been requested.
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::SeqCst)
|
||||
@@ -27,11 +38,6 @@ impl CancellationToken {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_starts_uncancelled() {
|
||||
assert!(!CancellationToken::new().is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_is_idempotent_and_visible_through_clones() {
|
||||
let token = CancellationToken::new();
|
||||
|
||||
@@ -8,9 +8,17 @@ use std::{
|
||||
|
||||
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
||||
|
||||
/// Configuration for one managed event subscriber.
|
||||
///
|
||||
/// Each subscriber has an independent bounded queue. The queue bounds memory
|
||||
/// growth when event production is faster than that subscriber's consumer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SubscriberConfig {
|
||||
/// Maximum number of events buffered for this subscriber.
|
||||
///
|
||||
/// Must be greater than zero.
|
||||
pub capacity: usize,
|
||||
/// Policy applied when publishing to a full subscriber queue.
|
||||
pub overflow: SubscriberOverflow,
|
||||
}
|
||||
|
||||
@@ -37,6 +45,11 @@ pub enum SubscriberOverflow {
|
||||
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)]
|
||||
pub struct EventHub {
|
||||
inner: Arc<HubInner>,
|
||||
@@ -75,6 +88,7 @@ struct SubscriberState {
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
/// Create an empty event hub with no subscribers.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
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(
|
||||
&self,
|
||||
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) {
|
||||
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
|
||||
.inner
|
||||
@@ -149,6 +171,8 @@ impl EventHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect all subscribers after leaving their already queued events
|
||||
/// available to drain.
|
||||
pub fn disconnect_all(&self) {
|
||||
let subscribers: Vec<Arc<SubscriberInner>> = self
|
||||
.inner
|
||||
@@ -290,14 +314,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_to_one_subscriber() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||
fn assert_next(
|
||||
sub: &EventSubscription,
|
||||
expected: Result<Option<&str>, EventSubscriptionError>,
|
||||
) {
|
||||
match (sub.try_recv(), expected) {
|
||||
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
|
||||
(Ok(None), Ok(None)) => {}
|
||||
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
|
||||
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,56 +339,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_newest_keeps_existing_events() {
|
||||
fn full_subscriber_overflow_policy_is_applied() {
|
||||
let cases = [
|
||||
(
|
||||
SubscriberOverflow::DropNewest,
|
||||
Ok(Some("event-1")),
|
||||
Ok(None),
|
||||
),
|
||||
(
|
||||
SubscriberOverflow::DropOldest,
|
||||
Ok(Some("event-2")),
|
||||
Ok(None),
|
||||
),
|
||||
(
|
||||
SubscriberOverflow::Disconnect,
|
||||
Err(EventSubscriptionError::Disconnected),
|
||||
Err(EventSubscriptionError::Disconnected),
|
||||
),
|
||||
];
|
||||
|
||||
for (overflow, first_expected, second_expected) in cases {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
overflow,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||
assert!(sub.try_recv().unwrap().is_none());
|
||||
assert_next(&sub, first_expected);
|
||||
assert_next(&sub, second_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_oldest_replaces_existing_events() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::DropOldest,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
|
||||
assert!(sub.try_recv().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_removes_full_subscriber() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::Disconnect,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(
|
||||
sub.try_recv().unwrap_err(),
|
||||
EventSubscriptionError::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,12 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
|
||||
const IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||
|
||||
/// Entry point for running a client session on a worker thread.
|
||||
///
|
||||
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
|
||||
/// opens the session, sends probes at the negotiated interval, receives
|
||||
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
|
||||
/// run completes or is cancelled.
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedClient;
|
||||
|
||||
@@ -46,26 +52,49 @@ pub struct ManagedClientSession {
|
||||
#[must_use = "managed session outcomes contain completion status and counters"]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionOutcome {
|
||||
/// Why the managed loop stopped.
|
||||
pub end_reason: SessionEndReason,
|
||||
/// Number of echo requests sent by the client.
|
||||
pub packets_sent: u64,
|
||||
/// Number of first in-window echo replies received.
|
||||
pub replies_received: u64,
|
||||
/// Number of duplicate reply events emitted.
|
||||
pub duplicates: u64,
|
||||
/// Number of late reply events emitted.
|
||||
pub late: u64,
|
||||
/// Number of warning events emitted.
|
||||
pub warning_events: u64,
|
||||
}
|
||||
|
||||
/// Reason a managed client session ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionEndReason {
|
||||
/// The negotiated finite test duration completed and the client closed the
|
||||
/// session normally.
|
||||
TestComplete,
|
||||
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
|
||||
/// dropping the session handle.
|
||||
Cancelled,
|
||||
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
|
||||
/// completed after negotiation.
|
||||
NoTestComplete,
|
||||
}
|
||||
|
||||
impl ManagedClient {
|
||||
/// Start a managed session without creating an initial event subscription.
|
||||
///
|
||||
/// The returned [`ManagedClientSession`] can still create subscriptions via
|
||||
/// [`ManagedClientSession::subscribe`], but events emitted before a
|
||||
/// subscriber is registered are not replayed.
|
||||
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
|
||||
Self::start_inner(config, None).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
/// Start a managed session and subscribe to events before the worker runs.
|
||||
///
|
||||
/// The initial subscription receives the open lifecycle event and
|
||||
/// subsequent session events, subject to its queue capacity and overflow
|
||||
/// policy.
|
||||
pub fn start_with_subscription(
|
||||
config: ClientConfig,
|
||||
subscriber_config: SubscriberConfig,
|
||||
@@ -114,14 +143,28 @@ impl ManagedClient {
|
||||
}
|
||||
|
||||
impl ManagedClientSession {
|
||||
/// Add another event subscriber to this running managed session.
|
||||
///
|
||||
/// The subscription receives only events published after it is registered.
|
||||
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
|
||||
self.hub.subscribe(config)
|
||||
}
|
||||
|
||||
/// Request cooperative cancellation of the managed session.
|
||||
///
|
||||
/// Cancellation is observed by the worker loop between socket receive,
|
||||
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
|
||||
/// wait for the worker and obtain the final [`SessionOutcome`].
|
||||
pub fn stop(&self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
|
||||
/// Wait for the managed worker thread to finish.
|
||||
///
|
||||
/// On success, returns lifecycle counters for the completed session. If the
|
||||
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
|
||||
/// also disconnects all event subscriptions while leaving already queued
|
||||
/// events available to drain.
|
||||
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
|
||||
let worker = self
|
||||
.worker
|
||||
|
||||
@@ -24,17 +24,6 @@ mod tests {
|
||||
use super::ReceiveMeta;
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
#[test]
|
||||
fn metadata_default_is_unavailable() {
|
||||
let meta = ReceiveMeta::default();
|
||||
let packet_meta = PacketMeta::from(meta);
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
|
||||
@@ -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]
|
||||
fn pending_map_rejects_over_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
@@ -220,7 +208,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_set_tracks_and_evicts() {
|
||||
fn bounded_probe_tracking_evicts_oldest_entries() {
|
||||
let mut set = CompletedSet::new(3);
|
||||
set.insert(0);
|
||||
set.insert(1);
|
||||
@@ -232,10 +220,7 @@ mod tests {
|
||||
assert_eq!(set.set.len(), 3);
|
||||
assert!(!set.contains(0));
|
||||
assert!(set.contains(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_tracks_and_evicts() {
|
||||
let mut map = TimedOutMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.insert(pending(0, now));
|
||||
|
||||
@@ -8,9 +8,16 @@ use crate::{
|
||||
probe::{CompletedSet, PendingMap, TimedOutMap},
|
||||
};
|
||||
|
||||
/// Protocol parameters accepted for a session.
|
||||
///
|
||||
/// `params` contains the server-returned values that the client will use for
|
||||
/// echo packets. `restrictions` records accepted differences from the request
|
||||
/// when loose negotiation is enabled.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NegotiatedParams {
|
||||
/// Server-returned protocol parameters used for the session.
|
||||
pub params: Params,
|
||||
/// Accepted server restrictions or parameter changes.
|
||||
pub restrictions: Vec<NegotiationRestriction>,
|
||||
}
|
||||
|
||||
@@ -57,6 +64,7 @@ pub enum NegotiationRestriction {
|
||||
}
|
||||
|
||||
impl NegotiationRestriction {
|
||||
/// Return a human-readable description of this negotiated restriction.
|
||||
pub fn message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
use std::time::{Instant, SystemTime};
|
||||
|
||||
/// Client-side timestamp captured in both wall-clock and monotonic time.
|
||||
///
|
||||
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
|
||||
/// calculations. Monotonic time is used for elapsed client-side timing such as
|
||||
/// RTT, scheduling, and timeouts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClientTimestamp {
|
||||
/// System wall-clock time.
|
||||
pub wall: SystemTime,
|
||||
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
|
||||
pub mono: Instant,
|
||||
}
|
||||
|
||||
impl ClientTimestamp {
|
||||
/// Capture the current wall-clock and monotonic timestamps.
|
||||
pub fn now() -> Self {
|
||||
Self {
|
||||
wall: SystemTime::now(),
|
||||
|
||||
@@ -122,34 +122,13 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_key_against_hmac_required_server_times_out() {
|
||||
let key = b"compat-secret".to_vec();
|
||||
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250));
|
||||
let mut config = config_for_params(server.addr, &default_params());
|
||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||
config.hmac_key = None;
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
|
||||
let observations = server.observations(1);
|
||||
assert!(matches!(
|
||||
observations.as_slice(),
|
||||
[ServerObservation::RejectedHmac {
|
||||
hmac: false,
|
||||
bad_hmac: true
|
||||
}]
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_client_key_against_hmac_required_server_times_out() {
|
||||
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
|
||||
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
|
||||
let server_key = b"compat-secret".to_vec();
|
||||
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
|
||||
let mut config = config_for_params(server.addr, &default_params());
|
||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||
config.hmac_key = Some(b"wrong-secret".to_vec());
|
||||
config.hmac_key = hmac_key;
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
@@ -158,12 +137,13 @@ fn wrong_client_key_against_hmac_required_server_times_out() {
|
||||
assert!(matches!(
|
||||
observations.as_slice(),
|
||||
[ServerObservation::RejectedHmac {
|
||||
hmac: true,
|
||||
hmac,
|
||||
bad_hmac: true
|
||||
}]
|
||||
}] if *hmac == expected_hmac_flag
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
|
||||
@@ -311,25 +291,15 @@ fn backend_hmac_correct_key_succeeds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_hmac_wrong_key_fails() {
|
||||
fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
|
||||
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
|
||||
let server_key = b"compat-secret".to_vec();
|
||||
let peer = BackendPeer::start_hmac_required(server_key);
|
||||
let mut config = config_for_params(peer.addr(), &default_params());
|
||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||
config.hmac_key = Some(b"wrong-secret".to_vec());
|
||||
config.hmac_key = hmac_key;
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
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)));
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
mod support;
|
||||
mod protocol_options {
|
||||
pub mod backend_smoke;
|
||||
}
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
|
||||
RttSample, ServerTiming,
|
||||
RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||
|
||||
use support::{
|
||||
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
|
||||
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
|
||||
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
|
||||
server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
|
||||
RECV_COUNT, RECV_WINDOW, TOKEN,
|
||||
};
|
||||
|
||||
use crate::support::test_echo_packet_len;
|
||||
@@ -66,7 +69,10 @@ fn timestamp_modes_drive_reply_event_timing_fields() {
|
||||
assert!(reply.server_timing.is_none());
|
||||
assert!(reply.one_way.is_none());
|
||||
assert!(reply.rtt.adjusted.is_none());
|
||||
assert_eq!(reply.rtt.effective, reply.rtt.raw);
|
||||
assert_eq!(
|
||||
reply.rtt.effective,
|
||||
SignedDuration::from_duration(reply.rtt.raw)
|
||||
);
|
||||
assert!(reply.rtt.raw > Duration::ZERO);
|
||||
}
|
||||
StampAt::Send => {
|
||||
@@ -346,7 +352,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
|
||||
assert_eq!(processing, Duration::from_nanos(1));
|
||||
let adjusted = reply.rtt.adjusted.unwrap();
|
||||
assert_eq!(reply.rtt.effective, adjusted);
|
||||
assert!(adjusted < reply.rtt.raw);
|
||||
assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
|
||||
}
|
||||
|
||||
fn assert_one_way_presence(
|
||||
@@ -364,46 +370,6 @@ fn assert_one_way_presence(
|
||||
assert_eq!(sample.server_to_client.is_some(), server_to_client);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_basic_open_echo_close() {
|
||||
let params = default_params();
|
||||
let peer = BackendPeer::start_open_echo(params.clone(), None);
|
||||
let mut client = Client::connect(config_for_params(peer.addr(), ¶ms)).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(), ¶ms);
|
||||
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 dscp_configured_open_close_smoke() {
|
||||
let mut params = default_params();
|
||||
@@ -416,117 +382,3 @@ fn dscp_configured_open_close_smoke() {
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_received_stats_smoke() {
|
||||
for mode in [
|
||||
ReceivedStats::None,
|
||||
ReceivedStats::Count,
|
||||
ReceivedStats::Window,
|
||||
ReceivedStats::Both,
|
||||
] {
|
||||
let params = params_for_modes(mode, StampAt::None, Clock::Both);
|
||||
let peer = BackendPeer::start_open_echo(params.clone(), None);
|
||||
let mut client = Client::connect(config_for_params(peer.addr(), ¶ms)).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(), ¶ms)).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(), ¶ms)).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(), ¶ms)).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(), ¶ms);
|
||||
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(), ¶ms)).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(), ¶ms)).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(), ¶ms)).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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
mod support;
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, NegotiationPolicy};
|
||||
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
|
||||
use irtt_proto::{Params, TimestampFields};
|
||||
|
||||
use support::{
|
||||
@@ -32,45 +32,27 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
|
||||
// ─── Config mapping ───
|
||||
|
||||
#[test]
|
||||
fn server_fill_none_produces_no_fill_in_open_params() {
|
||||
let params = default_params();
|
||||
let run = run_one_probe(params, TimestampFields::default());
|
||||
assert!(open_observation_params(&run).server_fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_fill_short_value_maps_into_open_params() {
|
||||
fn server_fill_none_short_and_max_map_into_open_params() {
|
||||
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
|
||||
let mut params = default_params();
|
||||
params.server_fill = server_fill("abc");
|
||||
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()),
|
||||
Some("abc")
|
||||
fill
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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() {
|
||||
fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
|
||||
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
|
||||
let config = ClientConfig {
|
||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
||||
server_fill: Some(fill.to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -78,17 +60,6 @@ fn server_fill_over_32_bytes_rejected_at_config_boundary() {
|
||||
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 ───
|
||||
@@ -102,12 +73,17 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_fill_strict_rejects_removed_fill() {
|
||||
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
|
||||
for (requested_fill, returned_fill) in [
|
||||
(Some("rand"), None),
|
||||
(Some("abc"), Some("xyz")),
|
||||
(None, Some("unexpected")),
|
||||
] {
|
||||
let mut requested = default_params();
|
||||
requested.server_fill = server_fill("rand");
|
||||
requested.server_fill = requested_fill.and_then(server_fill);
|
||||
|
||||
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 mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
|
||||
@@ -117,38 +93,6 @@ fn server_fill_strict_rejects_removed_fill() {
|
||||
));
|
||||
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]
|
||||
@@ -176,14 +120,6 @@ fn server_fill_loose_allows_server_to_remove_fill() {
|
||||
|
||||
// ─── Echo behavior ───
|
||||
|
||||
#[test]
|
||||
fn server_fill_echo_reply_decoded_with_fill_configured() {
|
||||
let mut params = default_params();
|
||||
params.server_fill = server_fill("rand");
|
||||
let run = run_one_probe(params, TimestampFields::default());
|
||||
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
|
||||
}
|
||||
|
||||
// server_fill is a negotiation/config parameter only; it does not affect
|
||||
// client-observable reply payload layout. The client sends server_fill in
|
||||
// the open request and expects the server to use it for reply payload
|
||||
|
||||
@@ -275,61 +275,3 @@ macro_rules! debug_eprintln {
|
||||
}
|
||||
|
||||
use debug_eprintln;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn startup_failure_details_include_bind_command_status_and_output() {
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("exit 7")
|
||||
.status()
|
||||
.expect("shell exits");
|
||||
let failure = StartupFailure {
|
||||
bind: "127.0.0.1:12345".to_owned(),
|
||||
command: "\"irtt\" \"server\"".to_owned(),
|
||||
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
|
||||
exit_status: Some(status),
|
||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
||||
};
|
||||
|
||||
let details = failure.details();
|
||||
|
||||
assert!(details.contains("bind: 127.0.0.1:12345"));
|
||||
assert!(details.contains("command: \"irtt\" \"server\""));
|
||||
assert!(details.contains("exit status:"));
|
||||
assert!(details.contains("address already in use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
|
||||
let failures = vec![
|
||||
StartupFailure {
|
||||
bind: "127.0.0.1:11111".to_owned(),
|
||||
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
|
||||
output: "stdout: first".to_owned(),
|
||||
exit_status: None,
|
||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
||||
},
|
||||
StartupFailure {
|
||||
bind: "127.0.0.1:22222".to_owned(),
|
||||
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
|
||||
output: "stderr: second".to_owned(),
|
||||
exit_status: None,
|
||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
||||
},
|
||||
];
|
||||
|
||||
let message = format_startup_failures("irtt", &failures);
|
||||
|
||||
assert!(message.contains("after 2 attempt(s)"));
|
||||
assert!(message.contains("attempt 1/2"));
|
||||
assert!(message.contains("attempt 2/2"));
|
||||
assert!(message.contains("127.0.0.1:11111"));
|
||||
assert!(message.contains("127.0.0.1:22222"));
|
||||
assert!(message.contains("stdout: first"));
|
||||
assert!(message.contains("stderr: second"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-proto"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "IRTT protocol encoding and decoding primitives"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -31,20 +31,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{hmac, FLAG_HMAC};
|
||||
|
||||
#[test]
|
||||
fn close_request_sizes() {
|
||||
let request = CloseRequest {
|
||||
token: 0x7896_b6ab_8771_5213,
|
||||
};
|
||||
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
|
||||
assert_eq!(
|
||||
encode_close_request(&request, Some(b"testkey"))
|
||||
.unwrap()
|
||||
.len(),
|
||||
28
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_close_request_places_token_after_hmac() {
|
||||
let packet = encode_close_request(
|
||||
|
||||
@@ -98,16 +98,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hmac_does_not_mutate_input() {
|
||||
let packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
||||
let original = packet.clone();
|
||||
|
||||
let _digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
||||
|
||||
assert_eq!(packet, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hmac_matches_copy_and_zero_semantics() {
|
||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
||||
@@ -119,42 +109,6 @@ mod tests {
|
||||
assert_eq!(digest, reference);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hmac_in_place_does_not_set_hmac_flag() {
|
||||
let mut packet = Vec::new();
|
||||
packet.extend_from_slice(&MAGIC);
|
||||
packet.push(FLAG_OPEN);
|
||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
||||
|
||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
||||
|
||||
assert_eq!(packet[3], FLAG_OPEN);
|
||||
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
|
||||
.iter()
|
||||
.any(|byte| *byte != 0));
|
||||
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hmac_in_place_only_writes_hmac_field() {
|
||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
||||
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
|
||||
let original = packet.clone();
|
||||
|
||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
||||
|
||||
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
|
||||
assert_ne!(
|
||||
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
|
||||
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
|
||||
);
|
||||
assert_eq!(
|
||||
&packet[hmac_offset() + HMAC_SIZE..],
|
||||
&original[hmac_offset() + HMAC_SIZE..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_hmac_rejects_modified_authenticated_byte() {
|
||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
||||
|
||||
@@ -155,6 +155,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn verified_layout_lengths() {
|
||||
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
||||
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
||||
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
||||
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
||||
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
||||
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
||||
assert_eq!(
|
||||
echo_header_len(
|
||||
false,
|
||||
@@ -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]
|
||||
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
||||
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, ¶ms(ReceivedStats::None, StampAt::None, clock)),
|
||||
16
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiated_length_never_smaller_than_header() {
|
||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
//! Low-level wire encode/decode support for the IRTT protocol.
|
||||
//!
|
||||
//! `irtt-proto` intentionally mirrors protocol fields closely: request/reply
|
||||
//! structs expose wire-oriented values, flags, counters, timestamps, and
|
||||
//! parameter enums rather than higher-level policy decisions. Higher-level
|
||||
//! crates such as `irtt-client` are responsible for session behavior and
|
||||
//! user-facing validation.
|
||||
//!
|
||||
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
|
||||
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
|
||||
//! `server_fill` values. Direct construction followed by [`Params::encode`]
|
||||
//! performs no additional validation, so callers that build `Params` manually
|
||||
//! are responsible for validating those values before sending them.
|
||||
//!
|
||||
//! The crate also provides packet layout calculation, open/echo/close packet
|
||||
//! encoding and decoding, and optional HMAC placement, computation, and
|
||||
//! verification helpers.
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod close;
|
||||
@@ -17,7 +35,7 @@ pub use flags::*;
|
||||
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
|
||||
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
|
||||
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
|
||||
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
|
||||
|
||||
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
|
||||
pub const PROTOCOL_VERSION: i64 = 1;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
|
||||
|
||||
/// Protocol compatibility bound for an encoded `server_fill` parameter.
|
||||
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||
|
||||
/// Low-level wire representation of IRTT open parameters.
|
||||
///
|
||||
/// `Params` mirrors the protocol fields closely and can be constructed
|
||||
/// directly by callers. Direct construction can therefore produce values that
|
||||
/// should not be sent on the wire, such as an oversized `server_fill` value.
|
||||
/// Higher-level callers should validate user or configuration input before
|
||||
/// encoding. The normal `irtt-client` configuration path enforces
|
||||
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Params {
|
||||
pub protocol_version: i64,
|
||||
@@ -21,6 +32,10 @@ impl Params {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes these parameters without performing additional validation.
|
||||
///
|
||||
/// Callers that construct `Params` directly are responsible for validating
|
||||
/// user or configuration input before encoding.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
push_int(1, self.protocol_version, &mut out);
|
||||
@@ -39,6 +54,10 @@ impl Params {
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes parameters and rejects malformed or incompatible incoming values.
|
||||
///
|
||||
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
|
||||
/// values longer than [`MAX_SERVER_FILL_BYTES`].
|
||||
pub fn decode(input: &[u8]) -> Result<Self> {
|
||||
let mut params = Self::default();
|
||||
let mut pos = 0;
|
||||
@@ -59,6 +78,12 @@ impl Params {
|
||||
pos += used;
|
||||
let len = usize::try_from(len)
|
||||
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
|
||||
if len > MAX_SERVER_FILL_BYTES {
|
||||
return Err(ProtoError::ParameterLengthTooLarge {
|
||||
tag,
|
||||
length: len as u64,
|
||||
});
|
||||
}
|
||||
if input.len().saturating_sub(pos) < len {
|
||||
return Err(ProtoError::MalformedParams);
|
||||
}
|
||||
@@ -206,6 +231,14 @@ mod tests {
|
||||
assert_eq!(Params::decode(¶ms.encode()), Ok(params));
|
||||
}
|
||||
|
||||
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
varint::encode_uvarint(9, &mut encoded);
|
||||
varint::encode_uvarint(value.len() as u64, &mut encoded);
|
||||
encoded.extend_from_slice(value);
|
||||
encoded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_round_trip() {
|
||||
let params = Params {
|
||||
@@ -225,7 +258,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_round_trip_received_stats_modes() {
|
||||
fn params_round_trip_negotiated_option_modes() {
|
||||
for received_stats in [
|
||||
ReceivedStats::None,
|
||||
ReceivedStats::Count,
|
||||
@@ -238,10 +271,7 @@ mod tests {
|
||||
..Params::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_round_trip_timestamp_modes() {
|
||||
for stamp_at in [
|
||||
StampAt::None,
|
||||
StampAt::Send,
|
||||
@@ -256,10 +286,7 @@ mod tests {
|
||||
..Params::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_round_trip_clock_modes() {
|
||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
||||
assert_round_trip(Params {
|
||||
protocol_version: 1,
|
||||
@@ -323,6 +350,29 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_fill_decode_accepts_max_length() {
|
||||
let value = b"0123456789abcdef0123456789abcdef";
|
||||
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
params.server_fill,
|
||||
Some(ServerFill {
|
||||
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_fill_decode_rejects_oversized_length() {
|
||||
let value = b"0123456789abcdef0123456789abcdefx";
|
||||
|
||||
assert_eq!(
|
||||
Params::decode(&encode_server_fill_value(value)),
|
||||
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
|
||||
let params = Params {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-stats"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Statistics aggregation for irtt-client events"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -69,8 +69,8 @@ impl CoreStats {
|
||||
StatsEvent::UniqueReply {
|
||||
is_late, sample, ..
|
||||
} => self.apply_unique_reply(is_late, *sample),
|
||||
StatsEvent::DuplicateReply { .. } => {
|
||||
self.apply_duplicate_reply();
|
||||
StatsEvent::DuplicateReply { bytes, .. } => {
|
||||
self.apply_duplicate_reply(bytes);
|
||||
EventStatsUpdate::default()
|
||||
}
|
||||
StatsEvent::Loss { .. } => {
|
||||
@@ -81,8 +81,8 @@ impl CoreStats {
|
||||
self.apply_warning();
|
||||
EventStatsUpdate::default()
|
||||
}
|
||||
StatsEvent::UntrackedLate { .. } => {
|
||||
self.apply_untracked_late();
|
||||
StatsEvent::UntrackedLate { bytes, .. } => {
|
||||
self.apply_untracked_late(bytes);
|
||||
EventStatsUpdate::default()
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,12 @@ impl CoreStats {
|
||||
.saturating_add(sample.bytes as u64);
|
||||
|
||||
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 {
|
||||
self.packets.server_received_window = Some(window);
|
||||
@@ -181,10 +186,11 @@ impl CoreStats {
|
||||
updates
|
||||
}
|
||||
|
||||
fn apply_duplicate_reply(&mut self) {
|
||||
fn apply_duplicate_reply(&mut self, bytes: usize) {
|
||||
self.events.duplicate_replies += 1;
|
||||
self.packets.packets_received += 1;
|
||||
self.packets.duplicates += 1;
|
||||
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
|
||||
}
|
||||
|
||||
fn apply_loss(&mut self) {
|
||||
@@ -195,8 +201,11 @@ impl CoreStats {
|
||||
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.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 {
|
||||
|
||||
@@ -48,11 +48,9 @@ impl IpdvTracker {
|
||||
if let Some(pair) = self.try_pair(seq) {
|
||||
pairs.push(pair);
|
||||
}
|
||||
if let Some(next) = seq.checked_add(1) {
|
||||
if let Some(pair) = self.try_pair(next) {
|
||||
if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
|
||||
pairs.push(pair);
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
@@ -66,15 +64,13 @@ impl IpdvTracker {
|
||||
};
|
||||
if self.samples.remove(&seq).is_some() {
|
||||
self.completed_pairs.remove(&seq);
|
||||
if let Some(next) = seq.checked_add(1) {
|
||||
self.completed_pairs.remove(&next);
|
||||
}
|
||||
self.completed_pairs.remove(&seq.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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]
|
||||
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
|
||||
let mut tracker = IpdvTracker::new(None);
|
||||
|
||||
@@ -198,6 +198,9 @@ pub struct EventCounts {
|
||||
/// Warning events processed.
|
||||
pub warning_events: u64,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -206,19 +209,26 @@ pub struct EventCounts {
|
||||
pub struct PacketCounts {
|
||||
/// Probe packets sent by the local client.
|
||||
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,
|
||||
/// Unique replies received by the local client.
|
||||
/// Unique replies that can contribute timing measurements.
|
||||
pub unique_replies: u64,
|
||||
/// Duplicate replies received by the local client.
|
||||
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,
|
||||
/// Probe bytes sent by the local client.
|
||||
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,
|
||||
/// Server-reported packets received, when available.
|
||||
/// Highest cumulative server-reported packets received, when available.
|
||||
pub server_packets_received: Option<u64>,
|
||||
/// Server-reported receive window, when available.
|
||||
pub server_received_window: Option<u64>,
|
||||
|
||||
@@ -22,6 +22,7 @@ pub(crate) enum StatsEvent {
|
||||
},
|
||||
DuplicateReply {
|
||||
at: Instant,
|
||||
bytes: usize,
|
||||
},
|
||||
Loss {
|
||||
at: Instant,
|
||||
@@ -31,6 +32,7 @@ pub(crate) enum StatsEvent {
|
||||
},
|
||||
UntrackedLate {
|
||||
at: Instant,
|
||||
bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,11 +98,17 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
|
||||
*bytes,
|
||||
)),
|
||||
}),
|
||||
ClientEvent::LateReply { received_at, .. } => Some(StatsEvent::UntrackedLate {
|
||||
ClientEvent::LateReply {
|
||||
received_at, bytes, ..
|
||||
} => Some(StatsEvent::UntrackedLate {
|
||||
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,
|
||||
bytes: *bytes,
|
||||
}),
|
||||
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
|
||||
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
|
||||
@@ -115,10 +123,10 @@ impl StatsEvent {
|
||||
match self {
|
||||
Self::Sent { at, .. }
|
||||
| Self::UniqueReply { at, .. }
|
||||
| Self::DuplicateReply { at }
|
||||
| Self::DuplicateReply { at, .. }
|
||||
| Self::Loss { at }
|
||||
| Self::Warning { at }
|
||||
| Self::UntrackedLate { at } => *at,
|
||||
| Self::UntrackedLate { at, .. } => *at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,17 +156,17 @@ impl ReplySample {
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
bytes: usize,
|
||||
) -> Self {
|
||||
let rtt_primary_ns = signed_duration_ns(rtt.effective_signed);
|
||||
let rtt_primary_ns = signed_duration_ns(rtt.effective);
|
||||
Self {
|
||||
bytes,
|
||||
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
|
||||
.and_then(|sample| sample.client_to_server)
|
||||
.map(duration_ns_i128),
|
||||
.map(signed_duration_ns),
|
||||
receive_delay_ns: one_way
|
||||
.and_then(|sample| sample.server_to_client)
|
||||
.map(duration_ns_i128),
|
||||
.map(signed_duration_ns),
|
||||
server_processing_ns: server_timing
|
||||
.and_then(|timing| timing.processing)
|
||||
.map(duration_ns_i128),
|
||||
@@ -195,7 +203,7 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
||||
}
|
||||
|
||||
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
||||
duration.ns
|
||||
duration.as_nanos()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 {
|
||||
raw: Duration::from_millis(raw_ms),
|
||||
adjusted: u64::try_from(effective_ms).ok().map(Duration::from_millis),
|
||||
effective: u64::try_from(effective_ms)
|
||||
.ok()
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or_else(|| Duration::from_millis(raw_ms)),
|
||||
adjusted_signed: Some(SignedDuration {
|
||||
ns: effective_ms * 1_000_000,
|
||||
}),
|
||||
effective_signed: SignedDuration {
|
||||
ns: effective_ms * 1_000_000,
|
||||
},
|
||||
adjusted: Some(adjusted),
|
||||
effective: adjusted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
|
||||
let raw = Duration::from_millis(raw_ms);
|
||||
RttSample {
|
||||
raw,
|
||||
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 received_at = ClientTimestamp {
|
||||
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(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: rtt(raw_ms, effective_ms),
|
||||
rtt,
|
||||
server_timing: Some(ServerTiming {
|
||||
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),
|
||||
@@ -63,8 +72,8 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
||||
processing: Some(Duration::from_millis(1)),
|
||||
}),
|
||||
one_way: Some(OneWayDelaySample {
|
||||
client_to_server: Some(Duration::from_millis(1)),
|
||||
server_to_client: Some(Duration::from_millis(2)),
|
||||
client_to_server: Some(SignedDuration::from_nanos(1_000_000)),
|
||||
server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
|
||||
}),
|
||||
received_stats: Some(ReceivedStatsSample {
|
||||
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 {
|
||||
seq,
|
||||
remote,
|
||||
@@ -88,7 +101,7 @@ pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
||||
bytes,
|
||||
packet_meta,
|
||||
..
|
||||
} = reply(seq, raw_ms, effective_ms)
|
||||
} = reply
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
mod common;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
use common::{late_reply, reply, sent, ts};
|
||||
use irtt_client::{ClientEvent, PacketMeta};
|
||||
use common::{adjusted_reply, sent, ts, unadjusted_late_reply, unadjusted_reply};
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
|
||||
|
||||
#[test]
|
||||
fn running_only_samples_avoid_finite_retention() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
||||
collector.process(&reply(0, 10, 9));
|
||||
collector.process(&reply(1, 20, 19));
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
collector.process(&unadjusted_reply(1, 20));
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
assert_eq!(snapshot.rtt.primary.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.ipdv.round_trip.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() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
||||
for seq in 0..4104 {
|
||||
collector.process(&reply(seq, 10, 10));
|
||||
collector.process(&unadjusted_reply(seq, 10));
|
||||
}
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
@@ -38,23 +41,24 @@ fn continuous_mode_tracks_running_samples_without_exact_medians() {
|
||||
#[test]
|
||||
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
collector.process(&reply(0, 1, -2));
|
||||
collector.process(&reply(1, 10, 8));
|
||||
collector.process(&adjusted_reply(0, 1, -2));
|
||||
collector.process(&adjusted_reply(1, 10, 8));
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
assert_eq!(snapshot.rtt.primary.count, 2);
|
||||
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.raw.total_ns, 11_000_000);
|
||||
assert_eq!(snapshot.rtt.adjusted.count, 2);
|
||||
}
|
||||
|
||||
#[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());
|
||||
collector.process(&sent(0, ts(0)));
|
||||
collector.process(&sent(1, ts(10)));
|
||||
collector.process(&reply(1, 10, 9));
|
||||
collector.process(&late_reply(0, 20, 19));
|
||||
collector.process(&unadjusted_reply(1, 10));
|
||||
collector.process(&unadjusted_late_reply(0, 20));
|
||||
collector.process(&ClientEvent::DuplicateReply {
|
||||
seq: 0,
|
||||
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.duplicates, 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.loss.lost_packets, 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),
|
||||
timeout_at: Instant::now(),
|
||||
});
|
||||
collector.process(&late_reply(0, 10, 9));
|
||||
collector.process(&unadjusted_late_reply(0, 10));
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
assert_eq!(snapshot.events.loss_events, 1);
|
||||
@@ -110,12 +115,40 @@ fn assert_one_ipdv_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]
|
||||
fn ipdv_is_sequence_adjacent_and_gap_preserving() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let first = collector.process(&reply(0, 10, 10));
|
||||
let gap = collector.process(&reply(2, 15, 15));
|
||||
let adjacent = collector.process(&reply(3, 12, 12));
|
||||
let first = collector.process(&unadjusted_reply(0, 10));
|
||||
let gap = collector.process(&unadjusted_reply(2, 15));
|
||||
let adjacent = collector.process(&unadjusted_reply(3, 12));
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn late_reply_can_complete_ipdv_pair() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
collector.process(&reply(1, 20, 20));
|
||||
let update = collector.process(&late_reply(0, 10, 10));
|
||||
collector.process(&unadjusted_reply(1, 20));
|
||||
let update = collector.process(&unadjusted_late_reply(0, 10));
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
|
||||
@@ -148,8 +214,8 @@ fn late_reply_can_complete_ipdv_pair() {
|
||||
#[test]
|
||||
fn update_exposes_directional_ipdv_when_available() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
collector.process(&reply(0, 10, 10));
|
||||
let update = collector.process(&reply(1, 13, 13));
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
let update = collector.process(&unadjusted_reply(1, 13));
|
||||
|
||||
assert!(update.contributed_sample);
|
||||
|
||||
@@ -162,9 +228,9 @@ fn update_exposes_directional_ipdv_when_available() {
|
||||
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
|
||||
let first = collector.process(&reply(0, 10, 10));
|
||||
let gap = collector.process(&reply(2, 20, 20));
|
||||
let fill = collector.process(&reply(1, 13, 13));
|
||||
let first = collector.process(&unadjusted_reply(0, 10));
|
||||
let gap = collector.process(&unadjusted_reply(2, 20));
|
||||
let fill = collector.process(&unadjusted_reply(1, 13));
|
||||
|
||||
assert!(first.contributed_sample);
|
||||
assert!(first.ipdv_pairs.is_empty());
|
||||
@@ -191,7 +257,7 @@ fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
||||
#[test]
|
||||
fn server_processing_and_one_way_require_available_samples() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
collector.process(&reply(0, 10, 9));
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
collector.process(&ClientEvent::LateReply {
|
||||
seq: 9,
|
||||
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.one_way_delay.send_delay.count, 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]
|
||||
@@ -220,8 +342,8 @@ fn rolling_count_eviction_recomputes_from_bounded_events() {
|
||||
rolling_time: None,
|
||||
});
|
||||
collector.process(&sent(0, ts(0)));
|
||||
collector.process(&reply(0, 10, 10));
|
||||
collector.process(&reply(1, 20, 20));
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
collector.process(&unadjusted_reply(1, 20));
|
||||
|
||||
let rolling = collector.rolling_count().unwrap();
|
||||
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());
|
||||
collector.process(&sent(0, ts(0)));
|
||||
collector.process(&sent(1, ts(10)));
|
||||
collector.process(&reply(0, 10, 10));
|
||||
collector.process(&unadjusted_reply(0, 10));
|
||||
|
||||
let loss = collector.snapshot().loss;
|
||||
assert_eq!(loss.lost_packets, 1);
|
||||
|
||||
+2
-1
@@ -4,10 +4,11 @@ members = ["cargo:."]
|
||||
# Config for 'dist'
|
||||
[dist]
|
||||
# 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 = "github"
|
||||
# The installers to generate for each app
|
||||
installers = []
|
||||
# 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"]
|
||||
features = ["tui"]
|
||||
@@ -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.
|
||||
Executable
+1143
File diff suppressed because it is too large.
Load diff
Executable
+716
@@ -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())
|
||||
Reference in new issue
Block a user