Compare commits
98
Commits
stats-test
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f39ebe8aa | ||
|
|
68c7f92cb4 | ||
|
|
d2ae68a05f | ||
|
|
f9c6728855 | ||
|
|
b9f5367ed1 | ||
|
|
c483a12173 | ||
|
|
c5ca3d5a93 | ||
|
|
baa2a862f5 | ||
|
|
e987d31f2f | ||
|
|
6602b7496a | ||
|
|
a62c53a77e | ||
|
|
836a8abbeb | ||
|
|
2065fe5e11 | ||
|
|
69fd24ce64 | ||
|
|
a8622ee15a | ||
|
|
5dce8d55ee | ||
|
|
543f0e57c8 | ||
|
|
acffc3cd4e | ||
|
|
ac82537043 | ||
|
|
170f229e7b | ||
|
|
5b40593fda | ||
|
|
b33f01cfe1 | ||
|
|
f1d442d798 | ||
|
|
d05d6a941b | ||
|
|
9c63a03454 | ||
|
|
b02bc53cd8 | ||
|
|
4b408d3ab4 | ||
|
|
f9f7d35793 | ||
|
|
62e0f0cf9f | ||
|
|
193487ae64 | ||
|
|
cfbac69ac6 | ||
|
|
f72f8fda5a | ||
|
|
c7d03c0358 | ||
|
|
97dc1b3f8d | ||
|
|
e33ae1d0fc | ||
|
|
d4976fb811 | ||
|
|
74ceef6a70 | ||
|
|
fe776e6672 | ||
|
|
efaa60cbd4 | ||
|
|
dc65daec75 | ||
|
|
e3b74441a7 | ||
|
|
5595d6e78e | ||
|
|
fe1f1a9c5b | ||
|
|
afe07f0b9a | ||
|
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: actionlint handwritten workflows
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||||
|
with:
|
||||||
|
files: >-
|
||||||
|
.github/workflows/actions-security.yml,
|
||||||
|
.github/workflows/ci.yml,
|
||||||
|
.github/workflows/interop-netem.yml,
|
||||||
|
.github/workflows/osv-scanner.yml,
|
||||||
|
.github/workflows/rust-clippy.yml,
|
||||||
|
.github/workflows/semver.yml,
|
||||||
|
.github/workflows/supply-chain.yml
|
||||||
|
|
||||||
|
- name: actionlint generated release workflow
|
||||||
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||||
|
with:
|
||||||
|
files: .github/workflows/release.yml
|
||||||
|
shellcheck: false
|
||||||
zizmor:
|
zizmor:
|
||||||
name: zizmor
|
name: zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -42,11 +56,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||||
with:
|
with:
|
||||||
persona: regular
|
persona: regular
|
||||||
@@ -24,12 +24,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
components: rustfmt
|
components: rustfmt
|
||||||
cache-key: fmt
|
cache-key: fmt
|
||||||
@@ -44,12 +44,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
cache-key: clippy
|
cache-key: clippy
|
||||||
@@ -63,6 +63,38 @@ jobs:
|
|||||||
--locked \
|
--locked \
|
||||||
-- -D warnings
|
-- -D warnings
|
||||||
|
|
||||||
|
- name: Run clippy without default features
|
||||||
|
run: |
|
||||||
|
cargo clippy \
|
||||||
|
--workspace \
|
||||||
|
--all-targets \
|
||||||
|
--no-default-features \
|
||||||
|
--locked \
|
||||||
|
-- -D warnings
|
||||||
|
|
||||||
|
check:
|
||||||
|
name: cargo check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
|
with:
|
||||||
|
cache-key: check
|
||||||
|
|
||||||
|
- name: Run check without default features
|
||||||
|
run: |
|
||||||
|
cargo check \
|
||||||
|
--workspace \
|
||||||
|
--no-default-features \
|
||||||
|
--locked
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: cargo test
|
name: cargo test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -70,12 +102,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
cache-key: test
|
cache-key: test
|
||||||
|
|
||||||
@@ -87,6 +119,13 @@ jobs:
|
|||||||
--all-features \
|
--all-features \
|
||||||
--locked
|
--locked
|
||||||
|
|
||||||
|
- name: Run tests without default features
|
||||||
|
run: |
|
||||||
|
cargo test \
|
||||||
|
--workspace \
|
||||||
|
--no-default-features \
|
||||||
|
--locked
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
name: cargo doc
|
name: cargo doc
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -97,12 +136,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
cache-key: docs
|
cache-key: docs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: Netem Interop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
GOBIN: ${{ github.workspace }}/.tools/bin
|
||||||
|
IRTT_UPSTREAM_VERSION: v0.9.1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
netem:
|
||||||
|
name: netem interop
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
|
with:
|
||||||
|
cache-key: interop-netem
|
||||||
|
|
||||||
|
- name: Check host tools
|
||||||
|
run: |
|
||||||
|
ip -V
|
||||||
|
tc -V
|
||||||
|
go version
|
||||||
|
|
||||||
|
- name: Install upstream irtt
|
||||||
|
run: |
|
||||||
|
mkdir -p "$GOBIN"
|
||||||
|
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
|
||||||
|
"$GOBIN/irtt" version
|
||||||
|
|
||||||
|
- name: Build irtt-rs CLI
|
||||||
|
run: cargo build -p irtt-cli --locked
|
||||||
|
|
||||||
|
- name: Run netem interop comparisons
|
||||||
|
run: |
|
||||||
|
python3 scripts/run_netem_interop_ci.py \
|
||||||
|
--all \
|
||||||
|
--skip-build \
|
||||||
|
--upstream-irtt "$GOBIN/irtt" \
|
||||||
|
--irtt-rs-command "$PWD/target/debug/irtt-cli"
|
||||||
|
|
||||||
|
- name: Append netem interop summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
||||||
|
if [ -n "$latest_summary" ]; then
|
||||||
|
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload netem interop artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: interop-netem
|
||||||
|
path: target/interop-netem
|
||||||
@@ -64,9 +64,9 @@ jobs:
|
|||||||
# we specify bash to get pipefail; it guards against the `curl` command
|
# we specify bash to get pipefail; it guards against the `curl` command
|
||||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||||
shell: bash
|
shell: bash
|
||||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
|
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
|
||||||
- name: Cache dist
|
- name: Cache dist
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: cargo-dist-cache
|
name: cargo-dist-cache
|
||||||
path: ~/.cargo/bin/dist
|
path: ~/.cargo/bin/dist
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
cat plan-dist-manifest.json
|
cat plan-dist-manifest.json
|
||||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
- name: "Upload dist-manifest.json"
|
- name: "Upload dist-manifest.json"
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: artifacts-plan-dist-manifest
|
name: artifacts-plan-dist-manifest
|
||||||
path: plan-dist-manifest.json
|
path: plan-dist-manifest.json
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
run: ${{ matrix.install_dist.run }}
|
run: ${{ matrix.install_dist.run }}
|
||||||
# Get the dist-manifest
|
# Get the dist-manifest
|
||||||
- name: Fetch local artifacts
|
- name: Fetch local artifacts
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: artifacts-*
|
pattern: artifacts-*
|
||||||
path: target/distrib/
|
path: target/distrib/
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
- name: "Upload artifacts"
|
- name: "Upload artifacts"
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||||
path: |
|
path: |
|
||||||
@@ -180,14 +180,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
- name: Install cached dist
|
- name: Install cached dist
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: cargo-dist-cache
|
name: cargo-dist-cache
|
||||||
path: ~/.cargo/bin/
|
path: ~/.cargo/bin/
|
||||||
- run: chmod +x ~/.cargo/bin/dist
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||||
- name: Fetch local artifacts
|
- name: Fetch local artifacts
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: artifacts-*
|
pattern: artifacts-*
|
||||||
path: target/distrib/
|
path: target/distrib/
|
||||||
@@ -205,7 +205,7 @@ jobs:
|
|||||||
|
|
||||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
- name: "Upload artifacts"
|
- name: "Upload artifacts"
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: artifacts-build-global
|
name: artifacts-build-global
|
||||||
path: |
|
path: |
|
||||||
@@ -230,14 +230,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
- name: Install cached dist
|
- name: Install cached dist
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: cargo-dist-cache
|
name: cargo-dist-cache
|
||||||
path: ~/.cargo/bin/
|
path: ~/.cargo/bin/
|
||||||
- run: chmod +x ~/.cargo/bin/dist
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
# Fetch artifacts from scratch-storage
|
# Fetch artifacts from scratch-storage
|
||||||
- name: Fetch artifacts
|
- name: Fetch artifacts
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: artifacts-*
|
pattern: artifacts-*
|
||||||
path: target/distrib/
|
path: target/distrib/
|
||||||
@@ -250,14 +250,14 @@ jobs:
|
|||||||
cat dist-manifest.json
|
cat dist-manifest.json
|
||||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
- name: "Upload dist-manifest.json"
|
- name: "Upload dist-manifest.json"
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
# Overwrite the previous copy
|
# Overwrite the previous copy
|
||||||
name: artifacts-dist-manifest
|
name: artifacts-dist-manifest
|
||||||
path: dist-manifest.json
|
path: dist-manifest.json
|
||||||
# Create a GitHub Release while uploading all files to it
|
# Create a GitHub Release while uploading all files to it
|
||||||
- name: "Download GitHub Artifacts"
|
- name: "Download GitHub Artifacts"
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: artifacts-*
|
pattern: artifacts-*
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
cache-key: clippy-sarif
|
cache-key: clippy-sarif
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
- name: Upload Clippy SARIF
|
- name: Upload Clippy SARIF
|
||||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: rust-clippy-results.sarif
|
sarif_file: rust-clippy-results.sarif
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run cargo-deny
|
- name: Run cargo-deny
|
||||||
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2
|
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
|
||||||
with:
|
with:
|
||||||
command: check
|
command: check
|
||||||
arguments: --all-features
|
arguments: --all-features
|
||||||
@@ -56,9 +56,24 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: actionlint handwritten workflows
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||||
|
with:
|
||||||
|
files: >-
|
||||||
|
.github/workflows/actions-security.yml,
|
||||||
|
.github/workflows/ci.yml,
|
||||||
|
.github/workflows/interop-netem.yml,
|
||||||
|
.github/workflows/osv-scanner.yml,
|
||||||
|
.github/workflows/rust-clippy.yml,
|
||||||
|
.github/workflows/semver.yml,
|
||||||
|
.github/workflows/supply-chain.yml
|
||||||
|
|
||||||
|
- name: actionlint generated release workflow
|
||||||
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||||
|
with:
|
||||||
|
files: .github/workflows/release.yml
|
||||||
|
shellcheck: false
|
||||||
Generated
+1571
-39
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -10,15 +10,15 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
rust-version = "1.75"
|
rust-version = "1.85"
|
||||||
repository = "https://github.com/Lochnair/irtt-rs"
|
repository = "https://github.com/Lochnair/irtt-rs"
|
||||||
homepage = "https://github.com/Lochnair/irtt-rs"
|
homepage = "https://github.com/Lochnair/irtt-rs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
|
||||||
irtt-client = { version = "0.2.0", path = "crates/irtt-client" }
|
irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
|
||||||
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" }
|
irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ It is not the upstream IRTT project. For the original implementation, protocol b
|
|||||||
|
|
||||||
This repository currently provides:
|
This repository currently provides:
|
||||||
|
|
||||||
- `irtt-cli`, a command-line IRTT-compatible client
|
- `irtt-rs`, the canonical multi-applet binary
|
||||||
|
- `irtt-cli`, a stream/text IRTT-compatible client applet
|
||||||
|
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
|
||||||
- `irtt-client`, a Rust library for running client sessions and consuming events
|
- `irtt-client`, a Rust library for running client sessions and consuming events
|
||||||
- finite and continuous probe runs
|
- finite and continuous probe runs
|
||||||
- human, simple, machine-readable, and RTT-only output modes
|
- table, CSV, TSV, and JSON Lines event-row output formats with selectable columns
|
||||||
- optional local summary statistics
|
- optional local summary statistics
|
||||||
|
|
||||||
Server support is not implemented.
|
Server support is not implemented.
|
||||||
|
The installed binary is intentionally not named `irtt` to avoid conflicting or
|
||||||
|
confusing overlap with upstream IRTT.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Requires Rust 1.75 or newer.
|
Requires Rust 1.85 or newer.
|
||||||
|
|
||||||
From a local checkout:
|
From a local checkout:
|
||||||
|
|
||||||
@@ -30,12 +34,48 @@ cd irtt-rs
|
|||||||
cargo install --path crates/irtt-cli
|
cargo install --path crates/irtt-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The default install provides the normal client-oriented binaries, including
|
||||||
|
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install --path crates/irtt-cli --features tui
|
||||||
|
```
|
||||||
|
|
||||||
Or run it directly without installing:
|
Or run it directly without installing:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p irtt-cli -- <server>
|
cargo run -p irtt-cli --bin irtt-cli -- <server>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
|
||||||
|
only `irtt-rs` and symlink or hardlink enabled applet names such as `irtt-cli`
|
||||||
|
and, when built with the `tui` feature, `irtt-tui` to it. Applet dispatch is
|
||||||
|
based on the invoked binary name. Server support is not implemented.
|
||||||
|
|
||||||
|
cargo-dist release archives are currently configured with the `full` feature
|
||||||
|
set, so target archives bundle the enabled applet binaries: `irtt-rs`,
|
||||||
|
`irtt-cli`, and `irtt-tui`. That is separate from space-sensitive package
|
||||||
|
layouts, which may still ship one dispatcher binary plus applet-name symlinks
|
||||||
|
or hardlinks. cargo-dist is not currently configured to create those links.
|
||||||
|
|
||||||
|
## Build verification
|
||||||
|
|
||||||
|
Useful local release/package sanity checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test -p irtt-cli
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo build -p irtt-cli --release
|
||||||
|
cargo build -p irtt-cli --no-default-features --release
|
||||||
|
cargo build -p irtt-cli --features tui --release
|
||||||
|
cargo build -p irtt-cli --all-features --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The no-default-features build only provides the `irtt-rs` dispatcher binary;
|
||||||
|
the default build provides `irtt-rs` and `irtt-cli`; `tui`, `full`, and
|
||||||
|
all-features builds also provide `irtt-tui`.
|
||||||
|
|
||||||
## CLI usage
|
## CLI usage
|
||||||
|
|
||||||
Basic test:
|
Basic test:
|
||||||
@@ -56,43 +96,123 @@ Run continuously:
|
|||||||
irtt-cli <server> --duration 0
|
irtt-cli <server> --duration 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a specific output mode:
|
Probe multiple targets concurrently:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli <server> --output human
|
irtt-cli host-a:2112 host-b:2112
|
||||||
irtt-cli <server> --output simple
|
irtt-cli --target ams=ams.example.com:2112 --target sg=sg.example.com:2112
|
||||||
irtt-cli <server> --output machine
|
irtt-cli host-a:2112 host-b:2112 --pacing burst
|
||||||
irtt-cli <server> --output rtt-us
|
```
|
||||||
|
|
||||||
|
For positional multi-target runs, the original target string is used as the
|
||||||
|
logical target label. Repeated positional strings get a stable numeric suffix.
|
||||||
|
For `--target label=host:port`, `label` is used in the `target` output column.
|
||||||
|
Labels must be unique. `--pacing staggered|burst` selects managed group pacing
|
||||||
|
for multi-target runs; the default is `staggered`.
|
||||||
|
|
||||||
|
Use a specific output format or column selection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-cli <server> --format table
|
||||||
|
irtt-cli <server> --format jsonl
|
||||||
|
irtt-cli <server> --format csv --columns event,seq,remote,effective_rtt_us
|
||||||
|
irtt-cli host-a:2112 host-b:2112 --format csv --columns target,seq,effective_rtt_us
|
||||||
|
irtt-cli <server> --format tsv --columns effective_rtt_us --header never
|
||||||
|
irtt-cli --list-columns
|
||||||
|
```
|
||||||
|
|
||||||
|
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
|
||||||
|
interactive probing. It defaults to continuous probing, equivalent to
|
||||||
|
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
|
||||||
|
session gracefully.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-tui <server>
|
||||||
|
irtt-tui <server> --duration 30s
|
||||||
|
irtt-tui host-a:2112 host-b:2112 --pacing burst
|
||||||
|
irtt-tui --target ams=ams.example.com:2112 --target sg=sg.example.com:2112
|
||||||
|
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
|
||||||
```
|
```
|
||||||
|
|
||||||
For available options:
|
For available options:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli --help
|
irtt-cli --help
|
||||||
|
irtt-tui --help # when built or installed with the tui feature
|
||||||
```
|
```
|
||||||
|
|
||||||
## Machine output
|
## Event row output
|
||||||
|
|
||||||
Use `--output machine` for line-oriented `key=value` output intended for scripts, monitoring, and autorate consumers.
|
The stream client separates event row rendering from final summaries. Event rows
|
||||||
|
are controlled by `--format`, `--columns`, and `--header`; final summaries remain
|
||||||
|
separate table-style text and are printed for table output when the run policy
|
||||||
|
allows a summary.
|
||||||
|
|
||||||
|
Formats:
|
||||||
|
|
||||||
|
- `table`: default, terminal-readable columns with a header
|
||||||
|
- `csv`: comma-separated rows, intended for scripts
|
||||||
|
- `tsv`: tab-separated rows, useful for one-value streams and shell pipelines
|
||||||
|
- `jsonl`: one JSON object per event row, suitable for streaming consumers
|
||||||
|
|
||||||
|
Headers are controlled with `--header auto|always|never`. In `auto`, table, CSV,
|
||||||
|
and TSV print one header row; JSON Lines never prints a header.
|
||||||
|
|
||||||
|
Columns are selected with `-c, --columns <COLUMNS>`, where `COLUMNS` is a
|
||||||
|
comma-separated list. Use `--list-columns` to print the available names. Useful
|
||||||
|
columns include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
target, event, seq, remote, token, rtt, rtt_us, raw_rtt_us, effective_rtt_us,
|
||||||
|
adjusted_rtt_us, rd, rd_us, sd, sd_us, ipdv, ipdv_us, proc,
|
||||||
|
server_processing_us, bytes, send_call_us, timer_error_us, highest_seen,
|
||||||
|
server_received, server_window, dscp, ecn, traffic_class, kernel_rx_ns,
|
||||||
|
warning_kind, message, event_wall_ns, client_send_wall_ns,
|
||||||
|
client_receive_wall_ns
|
||||||
|
```
|
||||||
|
|
||||||
|
Aliases are accepted for readability: `receive_delay` and `receive_delay_us`
|
||||||
|
for `rd` and `rd_us`, `send_delay` and `send_delay_us` for `sd` and `sd_us`,
|
||||||
|
`server_processing` for `proc`, `server_received_count` for
|
||||||
|
`server_received`, and `server_received_window` for `server_window`.
|
||||||
|
|
||||||
|
The default single-target table output favors readability with compact columns:
|
||||||
|
`event,seq,rtt,rd,sd,ipdv,proc,message`. It omits `echo_sent` rows. Passing
|
||||||
|
`--columns default` is the same as omitting `--columns`, including the default
|
||||||
|
table row filtering. Multi-target defaults add `target` as the first column so
|
||||||
|
rows can be attributed to a logical target label. Custom table column selections
|
||||||
|
include all event rows. CSV, TSV, and JSONL default to all non-target columns
|
||||||
|
for single-target export and include `target` by default for multi-target
|
||||||
|
export; use `--columns all` explicitly to request every column in any format.
|
||||||
|
Missing table values render as `-`; missing CSV and TSV values render as empty
|
||||||
|
fields; missing JSONL values render as `null`.
|
||||||
|
|
||||||
|
Use JSON Lines for structured event streaming:
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli <server> --duration 0 --interval 250ms --output machine
|
irtt-cli <server> --duration 0 --interval 250ms --format jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
Each line represents one client event and starts with an `event` field, for example:
|
Each line is one client event object, for example:
|
||||||
|
|
||||||
```text
|
```json
|
||||||
event=echo_reply seq=4 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","token":null,"rtt":"12.1ms","rtt_us":12100,"raw_rtt_us":12400,"effective_rtt_us":12100,"adjusted_rtt_us":12100,"rd":"6.0ms","rd_us":6000,"sd":"6.1ms","sd_us":6100,"ipdv":"300.0µs","ipdv_us":300,"proc":"300.0µs","server_processing_us":300,"bytes":64,"send_call_us":null,"timer_error_us":null,"highest_seen":null,"server_received":5,"server_window":"0x1f","dscp":0,"ecn":0,"traffic_class":0,"kernel_rx_ns":null,"warning_kind":null,"message":null,"event_wall_ns":1760000000012400000,"client_send_wall_ns":1760000000000000000,"client_receive_wall_ns":1760000000012400000}
|
||||||
```
|
```
|
||||||
|
|
||||||
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
|
Consumers should match on the `event` property and read the columns they need.
|
||||||
|
RTT, send-call, and timer-error `*_us` fields are reported in microseconds.
|
||||||
|
`raw_rtt_us` is the measured client send-to-receive RTT. `effective_rtt_us` and
|
||||||
|
`adjusted_rtt_us` are signed and may be negative when server processing exceeds
|
||||||
|
the raw RTT or timing correction produces a negative adjusted value. When
|
||||||
|
one-way delay fields are present, `sd_us` and `rd_us` are also signed
|
||||||
|
microseconds and may be negative due to wall-clock skew.
|
||||||
|
|
||||||
For consumers that only need RTT values, use:
|
For consumers that only need effective RTT values in microseconds, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli <server> --output rtt-us
|
irtt-cli <server> --format tsv --columns effective_rtt_us --header never
|
||||||
```
|
```
|
||||||
|
|
||||||
## Library usage
|
## Library usage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-cli"
|
name = "irtt-cli"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "Command-line IRTT-compatible client"
|
description = "Command-line IRTT-compatible client"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -11,13 +11,36 @@ readme.workspace = true
|
|||||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||||
categories = ["command-line-utilities", "network-programming"]
|
categories = ["command-line-utilities", "network-programming"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
doc = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "irtt-rs"
|
||||||
|
path = "src/bin/irtt-rs.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "irtt-cli"
|
||||||
|
path = "src/bin/irtt-cli.rs"
|
||||||
|
required-features = ["client"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "irtt-tui"
|
||||||
|
path = "src/bin/irtt-tui.rs"
|
||||||
|
required-features = ["tui"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["stats"]
|
default = ["client", "stats"]
|
||||||
stats = ["dep:irtt-stats"]
|
client-runtime = ["dep:irtt-client", "dep:irtt-proto"]
|
||||||
|
client = ["client-runtime"]
|
||||||
|
stats = ["client-runtime", "dep:irtt-stats"]
|
||||||
|
tui = ["client-runtime", "stats", "dep:ratatui", "dep:crossterm"]
|
||||||
|
full = ["client", "stats", "tui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
crossterm = { version = "0.29.0", optional = true }
|
||||||
ctrlc = "3.5.2"
|
ctrlc = "3.5.2"
|
||||||
irtt-client.workspace = true
|
irtt-client = { workspace = true, optional = true }
|
||||||
irtt-proto.workspace = true
|
irtt-proto = { workspace = true, optional = true }
|
||||||
irtt-stats = { workspace = true, optional = true }
|
irtt-stats = { workspace = true, optional = true }
|
||||||
|
ratatui = { version = "0.30.2", optional = true }
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
use std::{ffi::OsString, path::Path};
|
||||||
|
|
||||||
|
use clap::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RequestedApplet {
|
||||||
|
Client,
|
||||||
|
Tui,
|
||||||
|
Server,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AppletDispatch {
|
||||||
|
Run {
|
||||||
|
applet: RequestedApplet,
|
||||||
|
argv: Vec<OsString>,
|
||||||
|
},
|
||||||
|
Help(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
||||||
|
match applet_basename(argv0) {
|
||||||
|
"irtt-cli" => Some(RequestedApplet::Client),
|
||||||
|
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||||
|
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
||||||
|
"irtt-rs" => None,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
|
||||||
|
let argv0 = argv
|
||||||
|
.first()
|
||||||
|
.and_then(|arg| arg.to_str())
|
||||||
|
.unwrap_or("irtt-rs");
|
||||||
|
|
||||||
|
if let Some(applet) = detect_applet_from_argv0(argv0) {
|
||||||
|
return Ok(AppletDispatch::Run { applet, argv });
|
||||||
|
}
|
||||||
|
|
||||||
|
let basename = applet_basename(argv0);
|
||||||
|
if basename.starts_with("irtt-") && basename != "irtt-rs" {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown applet name '{basename}'. Known applet names: {}",
|
||||||
|
known_applet_binary_names()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
|
||||||
|
return Err(format!(
|
||||||
|
"choose an applet: {}\n\n{}",
|
||||||
|
applet_command_names(),
|
||||||
|
dispatcher_help()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||||
|
"client" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Client,
|
||||||
|
argv: applet_argv("irtt-cli", &argv[2..]),
|
||||||
|
}),
|
||||||
|
"tui" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Tui,
|
||||||
|
argv: applet_argv("irtt-tui", &argv[2..]),
|
||||||
|
}),
|
||||||
|
"server" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Server,
|
||||||
|
argv: applet_argv("irtt-server", &argv[2..]),
|
||||||
|
}),
|
||||||
|
_ => Err(format!(
|
||||||
|
"unknown applet '{command}'. Choose one of: {}\n\n{}",
|
||||||
|
applet_command_names(),
|
||||||
|
dispatcher_help()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatcher_help() -> String {
|
||||||
|
let mut command = Command::new("irtt-rs")
|
||||||
|
.about("IRTT-compatible multi-applet dispatcher")
|
||||||
|
.subcommand(Command::new("client").about(client_applet_about()))
|
||||||
|
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||||
|
.subcommand(Command::new("server").about("Server applet is not available in this build"))
|
||||||
|
.after_help(dispatcher_after_help());
|
||||||
|
command.render_help().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_applet_about() -> &'static str {
|
||||||
|
if cfg!(feature = "client") {
|
||||||
|
"Run the stream client applet"
|
||||||
|
} else {
|
||||||
|
"Stream client applet is not available in this build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_applet_about() -> &'static str {
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
"Run the terminal UI applet"
|
||||||
|
} else {
|
||||||
|
"Terminal UI applet is not available in this build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_applet_binary_names() -> &'static str {
|
||||||
|
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_command_names() -> &'static str {
|
||||||
|
"client, tui, or server"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher_after_help() -> &'static str {
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server"
|
||||||
|
} else if cfg!(feature = "client") {
|
||||||
|
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applet: irtt-tui requires the tui feature"
|
||||||
|
} else {
|
||||||
|
"Recognized applet binary names: irtt-cli, irtt-tui, irtt-server\nOptional applets: irtt-cli requires the client feature; irtt-tui requires the tui feature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_basename(argv0: &str) -> &str {
|
||||||
|
Path::new(argv0)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(argv0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||||
|
let mut argv = Vec::with_capacity(args.len() + 1);
|
||||||
|
argv.push(OsString::from(applet_name));
|
||||||
|
argv.extend(args.iter().cloned());
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applet_detection_maps_binary_names() {
|
||||||
|
assert_eq!(
|
||||||
|
detect_applet_from_argv0("/usr/bin/irtt-cli"),
|
||||||
|
Some(RequestedApplet::Client)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_applet_from_argv0("irtt-tui"),
|
||||||
|
Some(RequestedApplet::Tui)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_applet_from_argv0("irtt-server"),
|
||||||
|
Some(RequestedApplet::Server)
|
||||||
|
);
|
||||||
|
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||||
|
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_help_renders_dispatcher_help() {
|
||||||
|
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
||||||
|
let AppletDispatch::Help(help) = dispatch else {
|
||||||
|
panic!("expected dispatcher help");
|
||||||
|
};
|
||||||
|
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
||||||
|
assert!(help.contains("client"));
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
assert!(help.contains("Run the terminal UI applet"));
|
||||||
|
} else {
|
||||||
|
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||||
|
}
|
||||||
|
assert!(help.contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_without_applet_is_an_error() {
|
||||||
|
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
||||||
|
assert!(err.contains("choose an applet"));
|
||||||
|
assert!(err.contains("client"));
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
assert!(err.contains("tui"));
|
||||||
|
}
|
||||||
|
assert!(err.contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_subcommands_dispatch_to_applet_argv() {
|
||||||
|
assert_eq!(
|
||||||
|
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
||||||
|
.unwrap(),
|
||||||
|
AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Client,
|
||||||
|
argv: vec!["irtt-cli".into(), "host:2112".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
||||||
|
AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Tui,
|
||||||
|
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
||||||
|
AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Server,
|
||||||
|
argv: vec!["irtt-server".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_irtt_binary_name_is_an_error() {
|
||||||
|
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||||
|
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||||
|
assert!(err.contains("irtt-cli"));
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
assert!(err.contains("irtt-tui"));
|
||||||
|
}
|
||||||
|
assert!(err.contains("irtt-server"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
irtt_cli::entry::main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use irtt_client::ClientConfig;
|
||||||
|
|
||||||
|
use crate::shared::client::{
|
||||||
|
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs,
|
||||||
|
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec, TimestampArg,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
|
||||||
|
pub struct ClientArgs {
|
||||||
|
/// Server address or host, with optional port. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
value_name = "TARGET",
|
||||||
|
num_args = 0..,
|
||||||
|
long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-cli host-a:2112 host-b:2112."
|
||||||
|
)]
|
||||||
|
pub targets: Vec<String>,
|
||||||
|
|
||||||
|
/// Explicit labelled target, as label=host:port. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long = "target",
|
||||||
|
value_name = "LABEL=TARGET",
|
||||||
|
value_parser = parse_labelled_target,
|
||||||
|
long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used as the target column and must be unique."
|
||||||
|
)]
|
||||||
|
pub labelled_targets: Vec<LabelledTargetArg>,
|
||||||
|
|
||||||
|
/// Managed group pacing for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = GroupPacingArg::Staggered,
|
||||||
|
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||||
|
)]
|
||||||
|
pub pacing: GroupPacingArg,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "10s",
|
||||||
|
value_parser = parse_test_duration,
|
||||||
|
help = "Test duration; use 0 for continuous mode",
|
||||||
|
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
|
||||||
|
)]
|
||||||
|
pub duration: Duration,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub common: CommonClientArgs,
|
||||||
|
|
||||||
|
/// Event row output format.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = OutputFormat::Table,
|
||||||
|
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
||||||
|
)]
|
||||||
|
pub format: OutputFormat,
|
||||||
|
|
||||||
|
/// Comma-separated event row columns, or default/all.
|
||||||
|
#[arg(
|
||||||
|
short = 'c',
|
||||||
|
long,
|
||||||
|
value_name = "COLUMNS",
|
||||||
|
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
|
||||||
|
)]
|
||||||
|
pub columns: Option<String>,
|
||||||
|
|
||||||
|
/// List available event row columns and aliases, then exit.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
|
||||||
|
)]
|
||||||
|
pub list_columns: bool,
|
||||||
|
|
||||||
|
/// Header policy for table, CSV, and TSV output.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = HeaderMode::Auto,
|
||||||
|
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
|
||||||
|
)]
|
||||||
|
pub header: HeaderMode,
|
||||||
|
|
||||||
|
/// Include extra fields in table output and final summaries.
|
||||||
|
#[arg(long)]
|
||||||
|
pub verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientArgs {
|
||||||
|
pub fn to_client_config(&self) -> ClientConfig {
|
||||||
|
self.common.to_client_config(
|
||||||
|
self.primary_target_addr()
|
||||||
|
.expect("target is required unless --list-columns is set"),
|
||||||
|
self.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
self.duration == Duration::ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||||
|
self.common.tstamp
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_specs(&self) -> Result<Vec<CliTargetSpec>, String> {
|
||||||
|
target_specs(&self.targets, &self.labelled_targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedCliTarget>, String> {
|
||||||
|
let specs = self.target_specs()?;
|
||||||
|
let config = self.to_client_config();
|
||||||
|
resolved_managed_targets(specs, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary_target_addr(&self) -> Option<&str> {
|
||||||
|
self.targets.first().map(String::as_str).or_else(|| {
|
||||||
|
self.labelled_targets
|
||||||
|
.first()
|
||||||
|
.map(|target| target.addr.as_str())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ClientArgs {
|
||||||
|
type Target = CommonClientArgs;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
/// Readable terminal table output.
|
||||||
|
Table,
|
||||||
|
/// Comma-separated event rows.
|
||||||
|
Csv,
|
||||||
|
/// Tab-separated event rows.
|
||||||
|
Tsv,
|
||||||
|
/// One JSON object per event row.
|
||||||
|
Jsonl,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type CliTargetSpec = TargetSpec;
|
||||||
|
pub type ResolvedCliTarget = ResolvedTarget;
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
pub fn prints_summary(self) -> bool {
|
||||||
|
matches!(self, Self::Table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum HeaderMode {
|
||||||
|
/// Print headers for table, CSV, and TSV output.
|
||||||
|
Auto,
|
||||||
|
/// Always print headers where the format supports them.
|
||||||
|
Always,
|
||||||
|
/// Never print headers.
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
use irtt_client::NegotiationPolicy;
|
||||||
|
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
||||||
|
let mut argv = vec!["irtt-cli"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
ClientArgs::try_parse_from(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_parser_keeps_finite_default_duration() {
|
||||||
|
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(args.targets, ["127.0.0.1:2112"]);
|
||||||
|
assert!(args.labelled_targets.is_empty());
|
||||||
|
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||||
|
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||||
|
assert_eq!(args.interval, Duration::from_secs(1));
|
||||||
|
assert_eq!(args.format, OutputFormat::Table);
|
||||||
|
assert_eq!(args.columns, None);
|
||||||
|
assert_eq!(args.header, HeaderMode::Auto);
|
||||||
|
assert!(!args.is_continuous());
|
||||||
|
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||||
|
|
||||||
|
let config = args.to_client_config();
|
||||||
|
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
|
||||||
|
assert_eq!(config.received_stats, ReceivedStats::Both);
|
||||||
|
assert_eq!(config.stamp_at, StampAt::Both);
|
||||||
|
assert_eq!(config.clock, Clock::Both);
|
||||||
|
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
|
||||||
|
for format in ["table", "csv", "tsv", "jsonl"] {
|
||||||
|
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
|
||||||
|
}
|
||||||
|
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_columns_does_not_require_server() {
|
||||||
|
let args = parse(&["--list-columns"]).unwrap();
|
||||||
|
assert!(args.targets.is_empty());
|
||||||
|
assert!(args.list_columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_positional_targets_parse_as_targets() {
|
||||||
|
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "host-a:2112");
|
||||||
|
assert_eq!(specs[0].addr, "host-a:2112");
|
||||||
|
assert_eq!(specs[1].label, "host-b:2112");
|
||||||
|
assert_eq!(specs[1].addr, "host-b:2112");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_labelled_targets_parse() {
|
||||||
|
let args = parse(&[
|
||||||
|
"--target",
|
||||||
|
"ams=ams.example.com:2112",
|
||||||
|
"--target",
|
||||||
|
"sg=sg.example.com:2112",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "ams");
|
||||||
|
assert_eq!(specs[0].addr, "ams.example.com:2112");
|
||||||
|
assert_eq!(specs[1].label, "sg");
|
||||||
|
assert_eq!(specs[1].addr, "sg.example.com:2112");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_labels_are_rejected() {
|
||||||
|
let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap();
|
||||||
|
let err = args.target_specs().unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("duplicate target label"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||||
|
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "host-a:2112");
|
||||||
|
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_resolved_target_addresses_are_rejected() {
|
||||||
|
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||||
|
let err = args.resolved_managed_targets().unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_labelled_target_syntax_is_rejected() {
|
||||||
|
assert!(parse(&["--target", "missing-equals"]).is_err());
|
||||||
|
assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err());
|
||||||
|
assert!(parse(&["--target", "label="]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacing_option_accepts_supported_values() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||||
|
.unwrap()
|
||||||
|
.pacing,
|
||||||
|
GroupPacingArg::Staggered
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||||
|
.unwrap()
|
||||||
|
.pacing,
|
||||||
|
GroupPacingArg::Burst
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_help_lists_shared_protocol_options() {
|
||||||
|
let help = ClientArgs::command().render_help().to_string();
|
||||||
|
assert!(help.contains("--format <FORMAT>"));
|
||||||
|
assert!(help.contains("--target <LABEL=TARGET>"));
|
||||||
|
assert!(help.contains("--pacing <PACING>"));
|
||||||
|
assert!(help.contains("--columns <COLUMNS>"));
|
||||||
|
assert!(help.contains("--list-columns"));
|
||||||
|
assert!(help.contains("--header <HEADER>"));
|
||||||
|
assert!(help.contains("--tstamp <MODE>"));
|
||||||
|
assert!(help.contains("--stats <STATS>"));
|
||||||
|
assert!(help.contains("--sfill <STRING>"));
|
||||||
|
assert!(help.contains("--dscp <0..=63>"));
|
||||||
|
assert!(help.contains("--ttl <1..=255>"));
|
||||||
|
assert!(help.contains("--loose"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_format_summary_policy_is_table_only() {
|
||||||
|
assert!(OutputFormat::Table.prints_summary());
|
||||||
|
assert!(!OutputFormat::Csv.prints_summary());
|
||||||
|
assert!(!OutputFormat::Tsv.prints_summary());
|
||||||
|
assert!(!OutputFormat::Jsonl.prints_summary());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod args;
|
||||||
|
pub mod output;
|
||||||
|
pub mod run;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
pub mod summary;
|
||||||
|
|
||||||
|
pub use args::{ClientArgs, HeaderMode, OutputFormat};
|
||||||
|
pub use run::run_stream;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,766 @@
|
|||||||
|
#[cfg(feature = "stats")]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
io::{self, Write},
|
||||||
|
sync::atomic::AtomicBool,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_client::{
|
||||||
|
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
|
||||||
|
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow, TargetEvent,
|
||||||
|
};
|
||||||
|
#[cfg(all(test, feature = "stats"))]
|
||||||
|
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
args::{ClientArgs, ResolvedCliTarget},
|
||||||
|
output::{EventRenderStats, OutputConfig},
|
||||||
|
};
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
use crate::shared::client::expected_probe_count;
|
||||||
|
use crate::shared::client::{
|
||||||
|
is_shutdown_requested, session::should_print_final_summary, ClientSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
use irtt_stats::{StatsCollector, StatsConfig};
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const GIB: u64 = 1024 * MIB;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||||
|
|
||||||
|
const GROUP_IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
|
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
pub fn run_stream(
|
||||||
|
args: ClientArgs,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if args.list_columns {
|
||||||
|
print!("{}", OutputConfig::list_columns());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let output_config = OutputConfig::new(
|
||||||
|
args.format,
|
||||||
|
args.columns.as_deref(),
|
||||||
|
args.header,
|
||||||
|
args.verbose,
|
||||||
|
args.target_specs()
|
||||||
|
.map(|targets| targets.len() > 1)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
|
||||||
|
let targets = args
|
||||||
|
.resolved_managed_targets()
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
let multi_target = targets.len() > 1;
|
||||||
|
|
||||||
|
let continuous = args.is_continuous();
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
if let Some(warning) = finite_stats_memory_warning(&args) {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if multi_target {
|
||||||
|
return run_group_stream(args, targets, output_config, continuous, shutdown_requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_label = targets
|
||||||
|
.first()
|
||||||
|
.map(|target| target.label.as_str())
|
||||||
|
.expect("at least one target was validated");
|
||||||
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
let mut stats = StatsCollector::new(stats_config(continuous));
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config,
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut stdout,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = session.open()?;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
||||||
|
|
||||||
|
let mut interrupted = false;
|
||||||
|
while session.should_continue(shutdown_requested) {
|
||||||
|
let events = session.step(shutdown_requested)?;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
interrupted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.sleep_until_next_send();
|
||||||
|
}
|
||||||
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
|
|
||||||
|
if interrupted {
|
||||||
|
eprintln!("interrupted, closing session...");
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.should_drain_final(interrupted) {
|
||||||
|
session.drain_final(|events| {
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
let _ =
|
||||||
|
print_events_with_stats(&mut stream_output, events, Some(target_label), &mut stats);
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
let _ = print_events_with_stats(&mut stream_output, events, Some(target_label));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = session.poll_timeouts()?;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
||||||
|
|
||||||
|
let events = session.close()?;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label), &mut stats)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
print_events_with_stats(&mut stream_output, &events, Some(target_label))?;
|
||||||
|
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||||
|
stream_output.print_final_summary = print_final_summary;
|
||||||
|
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
stream_output.print_summary(&stats)?;
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
stream_output.print_summary()?;
|
||||||
|
stream_output.out.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamOutput<'a, W: Write> {
|
||||||
|
config: OutputConfig,
|
||||||
|
header_printed: bool,
|
||||||
|
print_final_summary: bool,
|
||||||
|
show_running_only_summary_note: bool,
|
||||||
|
out: &'a mut W,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> StreamOutput<'_, W> {
|
||||||
|
fn print_events(
|
||||||
|
&mut self,
|
||||||
|
events: &[ClientEvent],
|
||||||
|
target: Option<&str>,
|
||||||
|
stats_updates: &[EventRenderStats],
|
||||||
|
) -> io::Result<()> {
|
||||||
|
self.print_header()?;
|
||||||
|
for (event, stats_update) in events.iter().zip(stats_updates) {
|
||||||
|
self.print_event(event, target, stats_update)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_event(
|
||||||
|
&mut self,
|
||||||
|
event: &ClientEvent,
|
||||||
|
target: Option<&str>,
|
||||||
|
stats_update: &EventRenderStats,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
|
||||||
|
writeln!(self.out, "{line}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_header(&mut self) -> io::Result<()> {
|
||||||
|
if self.header_printed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.header_printed = true;
|
||||||
|
if let Some(header) = self.config.render_header() {
|
||||||
|
writeln!(self.out, "{header}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
||||||
|
if !self.print_final_summary || !self.config.prints_summary() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(
|
||||||
|
self.out,
|
||||||
|
"{}",
|
||||||
|
crate::cmd::client::summary::format_summary_with_options(
|
||||||
|
&stats.snapshot(),
|
||||||
|
crate::cmd::client::summary::SummaryFormatOptions {
|
||||||
|
verbose: self.config.summary_verbose(),
|
||||||
|
show_running_only_note: self.show_running_only_summary_note,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
fn print_summary(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn print_events_with_stats<W: Write>(
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
events: &[ClientEvent],
|
||||||
|
target: Option<&str>,
|
||||||
|
stats: &mut StatsCollector,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let stats_updates = events
|
||||||
|
.iter()
|
||||||
|
.map(|event| EventRenderStats::from(stats.process(event)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
stream_output.print_events(events, target, &stats_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
fn print_events_with_stats<W: Write>(
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
events: &[ClientEvent],
|
||||||
|
target: Option<&str>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let stats_updates = vec![EventRenderStats::default(); events.len()];
|
||||||
|
stream_output.print_events(events, target, &stats_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_group_stream(
|
||||||
|
args: ClientArgs,
|
||||||
|
targets: Vec<ResolvedCliTarget>,
|
||||||
|
output_config: OutputConfig,
|
||||||
|
continuous: bool,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut config = args.to_client_config();
|
||||||
|
let managed_targets = targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.managed.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let expected_target_count = managed_targets.len();
|
||||||
|
if let Some(first) = managed_targets.first() {
|
||||||
|
config.server_addr = first.remote.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let group_config = ManagedClientGroupConfig {
|
||||||
|
client: config,
|
||||||
|
pacing: args.pacing.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (session, events) = ManagedClientGroup::start_with_subscription(
|
||||||
|
group_config,
|
||||||
|
managed_targets,
|
||||||
|
SubscriberConfig {
|
||||||
|
capacity: 16_384,
|
||||||
|
// CLI output is best-effort under sustained backpressure. Dropping
|
||||||
|
// stale rows keeps continuous runs attached to the running group
|
||||||
|
// instead of turning a full output queue into a disconnected
|
||||||
|
// subscriber and a potentially blocking join.
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
let mut stats = targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| {
|
||||||
|
(
|
||||||
|
target.label.clone(),
|
||||||
|
StatsCollector::new(stats_config(continuous)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config,
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut stdout,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut interrupted = false;
|
||||||
|
let mut terminal_targets = HashSet::new();
|
||||||
|
let mut last_event_at = Instant::now();
|
||||||
|
let mut saw_target_event = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
interrupted = true;
|
||||||
|
session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(Some(target_event)) => {
|
||||||
|
last_event_at = Instant::now();
|
||||||
|
saw_target_event = true;
|
||||||
|
let label = target_event.target.as_str();
|
||||||
|
if is_terminal_target_event(&target_event.event) {
|
||||||
|
terminal_targets.insert(label.to_owned());
|
||||||
|
}
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
{
|
||||||
|
let stats = stats
|
||||||
|
.entry(label.to_owned())
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
|
||||||
|
print_target_event_with_stats(&mut stream_output, &target_event, stats)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
{
|
||||||
|
print_target_event_with_stats(&mut stream_output, &target_event)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
if interrupted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if terminal_targets.len() >= expected_target_count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if should_join_group_after_idle(&args, continuous, saw_target_event, last_event_at)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(GROUP_IDLE_SLEEP);
|
||||||
|
}
|
||||||
|
Err(EventSubscriptionError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if interrupted {
|
||||||
|
eprintln!("interrupted, closing group...");
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = session.join()?;
|
||||||
|
while let Ok(Some(target_event)) = events.try_recv() {
|
||||||
|
let label = target_event.target.as_str();
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
{
|
||||||
|
let stats = stats
|
||||||
|
.entry(label.to_owned())
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
|
||||||
|
print_target_event_with_stats(&mut stream_output, &target_event, stats)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
{
|
||||||
|
print_target_event_with_stats(&mut stream_output, &target_event)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if outcome.end_reason == ManagedGroupEndReason::Cancelled && !interrupted {
|
||||||
|
return Err("managed client group was cancelled".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||||
|
stream_output.print_final_summary = print_final_summary;
|
||||||
|
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
{
|
||||||
|
for (label, stats) in &stats {
|
||||||
|
if stream_output.print_final_summary && stream_output.config.prints_summary() {
|
||||||
|
writeln!(stream_output.out)?;
|
||||||
|
writeln!(stream_output.out, "target: {label}")?;
|
||||||
|
}
|
||||||
|
stream_output.print_summary(stats)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
{
|
||||||
|
stream_output.print_summary()?;
|
||||||
|
}
|
||||||
|
stream_output.out.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_terminal_target_event(event: &ClientEvent) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
ClientEvent::SessionClosed { .. } | ClientEvent::NoTestCompleted { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimated_group_completion_grace(args: &ClientArgs) -> Duration {
|
||||||
|
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
|
||||||
|
open_timeout
|
||||||
|
.saturating_add(args.duration)
|
||||||
|
.saturating_add(GROUP_COMPLETION_GRACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_join_group_after_idle(
|
||||||
|
args: &ClientArgs,
|
||||||
|
continuous: bool,
|
||||||
|
saw_target_event: bool,
|
||||||
|
last_event_at: Instant,
|
||||||
|
) -> bool {
|
||||||
|
if continuous && saw_target_event {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_event_at.elapsed() > estimated_group_completion_grace(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn print_target_event_with_stats<W: Write>(
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
target_event: &TargetEvent,
|
||||||
|
stats: &mut StatsCollector,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&target_event.event),
|
||||||
|
Some(target_event.target.as_str()),
|
||||||
|
stats,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
fn print_target_event_with_stats<W: Write>(
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
target_event: &TargetEvent,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&target_event.event),
|
||||||
|
Some(target_event.target.as_str()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn stats_config(continuous: bool) -> StatsConfig {
|
||||||
|
if continuous {
|
||||||
|
StatsConfig::continuous()
|
||||||
|
} else {
|
||||||
|
StatsConfig::finite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
|
||||||
|
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
|
||||||
|
if args.is_continuous() || args.duration.is_zero() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_probes = expected_probe_count(args.duration, args.interval);
|
||||||
|
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
|
||||||
|
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = format_bytes_for_warning(estimated_bytes);
|
||||||
|
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
|
||||||
|
"this may be unsuitable on memory-constrained systems"
|
||||||
|
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
|
||||||
|
"consider shortening the run, increasing the interval, or using continuous mode"
|
||||||
|
} else {
|
||||||
|
"use continuous mode for bounded-memory long-running tests"
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
fn format_bytes_for_warning(bytes: u64) -> String {
|
||||||
|
if bytes >= GIB {
|
||||||
|
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
|
||||||
|
} else {
|
||||||
|
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "stats"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
|
time::{Duration, Instant, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
||||||
|
ClientTimestamp {
|
||||||
|
wall: UNIX_EPOCH + offset,
|
||||||
|
mono: Instant::now() + offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_remote() -> SocketAddr {
|
||||||
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
|
||||||
|
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
|
||||||
|
let received_at = ClientTimestamp {
|
||||||
|
wall: sent_at.wall + Duration::from_micros(rtt_us),
|
||||||
|
mono: sent_at.mono + Duration::from_micros(rtt_us),
|
||||||
|
};
|
||||||
|
ClientEvent::EchoReply {
|
||||||
|
seq,
|
||||||
|
remote: test_remote(),
|
||||||
|
sent_at,
|
||||||
|
received_at,
|
||||||
|
rtt: RttSample {
|
||||||
|
raw: Duration::from_micros(rtt_us),
|
||||||
|
adjusted: None,
|
||||||
|
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: None,
|
||||||
|
received_stats: None,
|
||||||
|
bytes: 64,
|
||||||
|
packet_meta: PacketMeta::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_args(args: &[&str]) -> ClientArgs {
|
||||||
|
let mut argv = vec!["irtt-rs"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_config(
|
||||||
|
format: crate::cmd::client::OutputFormat,
|
||||||
|
columns: Option<&str>,
|
||||||
|
header: crate::cmd::client::HeaderMode,
|
||||||
|
verbose: bool,
|
||||||
|
) -> OutputConfig {
|
||||||
|
OutputConfig::new(format, columns, header, verbose, false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_helper_streams_and_collects_events() {
|
||||||
|
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||||
|
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
|
||||||
|
let events = [
|
||||||
|
ClientEvent::EchoSent {
|
||||||
|
seq: 1,
|
||||||
|
remote: test_remote(),
|
||||||
|
scheduled_at: sent_at.mono,
|
||||||
|
sent_at,
|
||||||
|
bytes: 64,
|
||||||
|
send_call: Duration::from_micros(10),
|
||||||
|
timer_error: Duration::ZERO,
|
||||||
|
},
|
||||||
|
ClientEvent::EchoReply {
|
||||||
|
seq: 1,
|
||||||
|
remote: test_remote(),
|
||||||
|
sent_at,
|
||||||
|
received_at,
|
||||||
|
rtt: RttSample {
|
||||||
|
raw: Duration::from_micros(1200),
|
||||||
|
adjusted: None,
|
||||||
|
effective: SignedDuration::from_nanos(1_200_000),
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: None,
|
||||||
|
received_stats: None,
|
||||||
|
bytes: 64,
|
||||||
|
packet_meta: PacketMeta::default(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut stats = StatsCollector::new(StatsConfig::finite());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config(
|
||||||
|
crate::cmd::client::OutputFormat::Tsv,
|
||||||
|
Some("effective_rtt_us"),
|
||||||
|
crate::cmd::client::HeaderMode::Never,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: true,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut out,
|
||||||
|
};
|
||||||
|
print_events_with_stats(&mut stream_output, &events, None, &mut stats).unwrap();
|
||||||
|
stream_output.print_summary(&stats).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = String::from_utf8(out).unwrap();
|
||||||
|
let summary = stats.snapshot();
|
||||||
|
assert!(!rendered.is_empty());
|
||||||
|
assert_eq!(summary.packets.packets_sent, 1);
|
||||||
|
assert_eq!(summary.packets.unique_replies, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
|
||||||
|
let mut stats = StatsCollector::new(StatsConfig::continuous());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config(
|
||||||
|
crate::cmd::client::OutputFormat::Table,
|
||||||
|
None,
|
||||||
|
crate::cmd::client::HeaderMode::Auto,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: true,
|
||||||
|
show_running_only_summary_note: true,
|
||||||
|
out: &mut out,
|
||||||
|
};
|
||||||
|
print_events_with_stats(
|
||||||
|
&mut stream_output,
|
||||||
|
&[reply_event(1, 1200)],
|
||||||
|
None,
|
||||||
|
&mut stats,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stream_output.print_summary(&stats).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = String::from_utf8(out).unwrap();
|
||||||
|
assert!(rendered.contains("irtt-rs summary"));
|
||||||
|
assert!(rendered.contains("medians unavailable"));
|
||||||
|
assert!(rendered.contains("continuous mode"));
|
||||||
|
assert!(rendered.contains("packets:"));
|
||||||
|
assert!(rendered.contains("received=1"));
|
||||||
|
|
||||||
|
let stats = StatsCollector::new(StatsConfig::continuous());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config(
|
||||||
|
crate::cmd::client::OutputFormat::Table,
|
||||||
|
None,
|
||||||
|
crate::cmd::client::HeaderMode::Auto,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: true,
|
||||||
|
out: &mut out,
|
||||||
|
};
|
||||||
|
stream_output.print_summary(&stats).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_mode_uses_continuous_stats_config() {
|
||||||
|
let mut collector = StatsCollector::new(stats_config(true));
|
||||||
|
for seq in 0..5000_u32 {
|
||||||
|
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
|
||||||
|
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
|
||||||
|
collector.process(&ClientEvent::EchoReply {
|
||||||
|
seq,
|
||||||
|
remote: test_remote(),
|
||||||
|
sent_at,
|
||||||
|
received_at,
|
||||||
|
rtt: RttSample {
|
||||||
|
raw: Duration::from_micros(1),
|
||||||
|
adjusted: None,
|
||||||
|
effective: SignedDuration::from_nanos(1_000),
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: None,
|
||||||
|
received_stats: None,
|
||||||
|
bytes: 64,
|
||||||
|
packet_meta: PacketMeta::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
|
||||||
|
for args in [
|
||||||
|
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
|
||||||
|
cli_args(&[
|
||||||
|
"--duration",
|
||||||
|
"1000ms",
|
||||||
|
"--interval",
|
||||||
|
"1ms",
|
||||||
|
"127.0.0.1:2112",
|
||||||
|
]),
|
||||||
|
] {
|
||||||
|
assert_eq!(finite_stats_memory_warning(&args), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (threshold, size, guidance) in [
|
||||||
|
(
|
||||||
|
FINITE_STATS_MEMORY_WARNING_BYTES,
|
||||||
|
"about 128 MiB",
|
||||||
|
"bounded-memory long-running tests",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
|
||||||
|
"about 512 MiB",
|
||||||
|
"shortening the run",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
|
||||||
|
"about 1 GiB",
|
||||||
|
"memory-constrained systems",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
|
||||||
|
let args = cli_args(&[
|
||||||
|
"--duration",
|
||||||
|
&format!("{expected_probes}ms"),
|
||||||
|
"--interval",
|
||||||
|
"1ms",
|
||||||
|
"127.0.0.1:2112",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let warning = finite_stats_memory_warning(&args).unwrap();
|
||||||
|
assert!(warning.contains(size));
|
||||||
|
assert!(warning.contains(guidance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expected_probe_count_rounds_up() {
|
||||||
|
assert_eq!(
|
||||||
|
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn estimate_finite_stats_memory_bytes_saturates() {
|
||||||
|
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub fn format_summary(summary: &Snapshot) -> String {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct SummaryFormatOptions {
|
pub struct SummaryFormatOptions {
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
pub show_running_only_note: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
|
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
|
||||||
@@ -18,6 +19,13 @@ pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOpt
|
|||||||
|
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(out, "irtt-rs summary").unwrap();
|
writeln!(out, "irtt-rs summary").unwrap();
|
||||||
|
if options.show_running_only_note {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
@@ -198,10 +206,8 @@ mod tests {
|
|||||||
received_at,
|
received_at,
|
||||||
rtt: RttSample {
|
rtt: RttSample {
|
||||||
raw: Duration::from_micros(1500),
|
raw: Duration::from_micros(1500),
|
||||||
adjusted: Some(Duration::from_micros(1200)),
|
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||||
effective: Duration::from_micros(1200),
|
effective: SignedDuration::from_nanos(1_200_000),
|
||||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
|
||||||
effective_signed: SignedDuration { ns: 1_200_000 },
|
|
||||||
},
|
},
|
||||||
server_timing: Some(ServerTiming {
|
server_timing: Some(ServerTiming {
|
||||||
receive_wall_ns: None,
|
receive_wall_ns: None,
|
||||||
@@ -232,6 +238,7 @@ mod tests {
|
|||||||
assert!(output.contains("10.0µs"));
|
assert!(output.contains("10.0µs"));
|
||||||
assert!(output.contains("timer error"));
|
assert!(output.contains("timer error"));
|
||||||
assert!(output.contains("2.0µs"));
|
assert!(output.contains("2.0µs"));
|
||||||
|
assert!(!output.contains("medians unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -245,10 +252,8 @@ mod tests {
|
|||||||
received_at,
|
received_at,
|
||||||
rtt: RttSample {
|
rtt: RttSample {
|
||||||
raw: Duration::from_micros(1500),
|
raw: Duration::from_micros(1500),
|
||||||
adjusted: Some(Duration::from_micros(1200)),
|
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||||
effective: Duration::from_micros(1200),
|
effective: SignedDuration::from_nanos(1_200_000),
|
||||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
|
||||||
effective_signed: SignedDuration { ns: 1_200_000 },
|
|
||||||
},
|
},
|
||||||
server_timing: None,
|
server_timing: None,
|
||||||
one_way: None,
|
one_way: None,
|
||||||
@@ -259,13 +264,32 @@ mod tests {
|
|||||||
|
|
||||||
let output = format_summary_with_options(
|
let output = format_summary_with_options(
|
||||||
&collector.snapshot(),
|
&collector.snapshot(),
|
||||||
SummaryFormatOptions { verbose: true },
|
SummaryFormatOptions {
|
||||||
|
verbose: true,
|
||||||
|
..SummaryFormatOptions::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(output.contains("raw RTT"));
|
assert!(output.contains("raw RTT"));
|
||||||
assert!(output.contains("adjusted RTT"));
|
assert!(output.contains("adjusted RTT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_only_note_is_format_option() {
|
||||||
|
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
|
||||||
|
let output = format_summary_with_options(
|
||||||
|
&summary,
|
||||||
|
SummaryFormatOptions {
|
||||||
|
show_running_only_note: true,
|
||||||
|
..SummaryFormatOptions::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(output.contains("irtt-rs summary"));
|
||||||
|
assert!(output.contains("medians unavailable"));
|
||||||
|
assert!(output.contains("continuous mode"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn signed_stats_can_format_negative_values() {
|
fn signed_stats_can_format_negative_values() {
|
||||||
let stats = TimeStats {
|
let stats = TimeStats {
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#[cfg(feature = "client")]
|
||||||
|
pub mod client;
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
pub mod tui;
|
||||||
|
|
||||||
|
// Additional command applets belong here behind their own feature gate.
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use irtt_client::ClientConfig;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::shared::client::TimestampArg;
|
||||||
|
use crate::shared::client::{
|
||||||
|
parse_labelled_target, parse_test_duration, resolved_managed_targets, target_specs,
|
||||||
|
CommonClientArgs, GroupPacingArg, LabelledTargetArg, ResolvedTarget, TargetSpec,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||||
|
pub struct TuiArgs {
|
||||||
|
/// Server address or host, with optional port. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
value_name = "TARGET",
|
||||||
|
num_args = 0..,
|
||||||
|
required_unless_present = "labelled_targets",
|
||||||
|
long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-tui host-a:2112 host-b:2112."
|
||||||
|
)]
|
||||||
|
pub targets: Vec<String>,
|
||||||
|
|
||||||
|
/// Explicit labelled target, as label=host:port. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long = "target",
|
||||||
|
value_name = "LABEL=TARGET",
|
||||||
|
value_parser = parse_labelled_target,
|
||||||
|
long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used in the legend and status table and must be unique."
|
||||||
|
)]
|
||||||
|
pub labelled_targets: Vec<LabelledTargetArg>,
|
||||||
|
|
||||||
|
/// Managed group pacing for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = GroupPacingArg::Staggered,
|
||||||
|
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||||
|
)]
|
||||||
|
pub pacing: GroupPacingArg,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "0",
|
||||||
|
value_parser = parse_test_duration,
|
||||||
|
help = "Test duration; use 0 for continuous mode",
|
||||||
|
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
||||||
|
)]
|
||||||
|
pub duration: Duration,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub common: CommonClientArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiArgs {
|
||||||
|
pub fn to_client_config(&self) -> ClientConfig {
|
||||||
|
self.common.to_client_config(
|
||||||
|
self.primary_target_addr()
|
||||||
|
.expect("at least one target is required"),
|
||||||
|
self.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
self.duration == Duration::ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_specs(&self) -> Result<Vec<TuiTargetSpec>, String> {
|
||||||
|
target_specs(&self.targets, &self.labelled_targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedTuiTarget>, String> {
|
||||||
|
let specs = self.target_specs()?;
|
||||||
|
let config = self.to_client_config();
|
||||||
|
resolved_managed_targets(specs, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||||
|
self.common.tstamp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary_target_addr(&self) -> Option<&str> {
|
||||||
|
self.targets.first().map(String::as_str).or_else(|| {
|
||||||
|
self.labelled_targets
|
||||||
|
.first()
|
||||||
|
.map(|target| target.addr.as_str())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type TuiTargetSpec = TargetSpec;
|
||||||
|
pub type ResolvedTuiTarget = ResolvedTarget;
|
||||||
|
|
||||||
|
impl std::ops::Deref for TuiArgs {
|
||||||
|
type Target = CommonClientArgs;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct SharedOnlyArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
common: CommonClientArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
||||||
|
let mut argv = vec!["irtt-tui"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
TuiArgs::try_parse_from(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
|
||||||
|
let mut argv = vec!["shared-only"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
SharedOnlyArgs::try_parse_from(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||||
|
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||||
|
assert_eq!(args.targets, ["127.0.0.1:2112"]);
|
||||||
|
assert!(args.labelled_targets.is_empty());
|
||||||
|
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||||
|
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||||
|
assert!(args.is_continuous());
|
||||||
|
assert_eq!(args.to_client_config().duration, None);
|
||||||
|
|
||||||
|
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
||||||
|
assert_eq!(finite.duration, Duration::from_secs(30));
|
||||||
|
assert_eq!(
|
||||||
|
finite.to_client_config().duration,
|
||||||
|
Some(Duration::from_secs(30))
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
||||||
|
let help = TuiArgs::command().render_help().to_string();
|
||||||
|
assert!(!help.contains("--output"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_positional_targets_parse() {
|
||||||
|
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "host-a:2112");
|
||||||
|
assert_eq!(specs[0].addr, "host-a:2112");
|
||||||
|
assert_eq!(specs[1].label, "host-b:2112");
|
||||||
|
assert_eq!(specs[1].addr, "host-b:2112");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_labelled_targets_parse() {
|
||||||
|
let args = parse(&[
|
||||||
|
"--target",
|
||||||
|
"ams=ams.example.com:2112",
|
||||||
|
"--target",
|
||||||
|
"sg=sg.example.com:2112",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "ams");
|
||||||
|
assert_eq!(specs[0].addr, "ams.example.com:2112");
|
||||||
|
assert_eq!(specs[1].label, "sg");
|
||||||
|
assert_eq!(specs[1].addr, "sg.example.com:2112");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn at_least_one_target_is_required() {
|
||||||
|
assert!(parse(&[]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_labels_are_rejected() {
|
||||||
|
let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap();
|
||||||
|
let err = args.target_specs().unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("duplicate target label"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||||
|
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||||
|
let specs = args.target_specs().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "host-a:2112");
|
||||||
|
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_resolved_target_addresses_are_rejected() {
|
||||||
|
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||||
|
let err = args.resolved_managed_targets().unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_labelled_target_syntax_is_rejected() {
|
||||||
|
assert!(parse(&["--target", "missing-equals"]).is_err());
|
||||||
|
assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err());
|
||||||
|
assert!(parse(&["--target", "label="]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacing_option_accepts_supported_values() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||||
|
.unwrap()
|
||||||
|
.pacing,
|
||||||
|
GroupPacingArg::Staggered
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||||
|
.unwrap()
|
||||||
|
.pacing,
|
||||||
|
GroupPacingArg::Burst
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_client_options_match_tui_config_mapping() {
|
||||||
|
let shared = [
|
||||||
|
"--interval",
|
||||||
|
"250ms",
|
||||||
|
"--length",
|
||||||
|
"128",
|
||||||
|
"--hmac",
|
||||||
|
"secret",
|
||||||
|
"--clock",
|
||||||
|
"monotonic",
|
||||||
|
"--tstamp",
|
||||||
|
"receive",
|
||||||
|
"--stats",
|
||||||
|
"count",
|
||||||
|
"--sfill",
|
||||||
|
"abc",
|
||||||
|
"--dscp",
|
||||||
|
"46",
|
||||||
|
"--ttl",
|
||||||
|
"64",
|
||||||
|
"--loose",
|
||||||
|
];
|
||||||
|
let tui_args = [
|
||||||
|
"--duration",
|
||||||
|
"30s",
|
||||||
|
"--interval",
|
||||||
|
"250ms",
|
||||||
|
"--length",
|
||||||
|
"128",
|
||||||
|
"--hmac",
|
||||||
|
"secret",
|
||||||
|
"--clock",
|
||||||
|
"monotonic",
|
||||||
|
"--tstamp",
|
||||||
|
"receive",
|
||||||
|
"--stats",
|
||||||
|
"count",
|
||||||
|
"--sfill",
|
||||||
|
"abc",
|
||||||
|
"--dscp",
|
||||||
|
"46",
|
||||||
|
"--ttl",
|
||||||
|
"64",
|
||||||
|
"--loose",
|
||||||
|
"127.0.0.1:2112",
|
||||||
|
];
|
||||||
|
let tui = parse(&tui_args).unwrap().to_client_config();
|
||||||
|
let common = parse_shared(&shared).unwrap().common;
|
||||||
|
let shared = common.to_client_config("127.0.0.1:2112", Duration::from_secs(30));
|
||||||
|
|
||||||
|
assert_eq!(shared.server_addr, tui.server_addr);
|
||||||
|
assert_eq!(shared.duration, tui.duration);
|
||||||
|
assert_eq!(shared.interval, tui.interval);
|
||||||
|
assert_eq!(shared.length, tui.length);
|
||||||
|
assert_eq!(shared.received_stats, tui.received_stats);
|
||||||
|
assert_eq!(shared.stamp_at, tui.stamp_at);
|
||||||
|
assert_eq!(shared.clock, tui.clock);
|
||||||
|
assert_eq!(shared.dscp, tui.dscp);
|
||||||
|
assert_eq!(shared.hmac_key, tui.hmac_key);
|
||||||
|
assert_eq!(shared.server_fill, tui.server_fill);
|
||||||
|
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
|
||||||
|
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_help_lists_multi_target_options() {
|
||||||
|
let help = TuiArgs::command().render_help().to_string();
|
||||||
|
assert!(help.contains("--target <LABEL=TARGET>"));
|
||||||
|
assert!(help.contains("--pacing <PACING>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod args;
|
||||||
|
pub mod run;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
pub use args::TuiArgs;
|
||||||
|
pub use run::run_tui;
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use irtt_client::{
|
||||||
|
ClientEvent, EventSubscriptionError, ManagedClientGroup, ManagedClientGroupConfig,
|
||||||
|
ManagedGroupEndReason, SubscriberConfig, SubscriberOverflow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cmd::tui::args::{ResolvedTuiTarget, TuiArgs},
|
||||||
|
shared::client::{is_shutdown_requested, ClientSession},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||||
|
|
||||||
|
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||||
|
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
|
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
pub fn run_tui(
|
||||||
|
args: TuiArgs,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let targets = args
|
||||||
|
.resolved_managed_targets()
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
let continuous = args.is_continuous();
|
||||||
|
let mut terminal = TuiTerminal::enter()?;
|
||||||
|
let mut state = TuiState::with_target_labels(
|
||||||
|
TuiConfig::from_args(&args),
|
||||||
|
targets.iter().map(|target| target.label.clone()),
|
||||||
|
);
|
||||||
|
let mut next_render = Instant::now();
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_status(TuiStatus::Opening);
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
|
||||||
|
if targets.len() > 1 {
|
||||||
|
return run_group_tui(
|
||||||
|
args,
|
||||||
|
targets,
|
||||||
|
&mut terminal,
|
||||||
|
&mut state,
|
||||||
|
&mut next_render,
|
||||||
|
shutdown_requested,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(err) => {
|
||||||
|
state.set_error(err.to_string());
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
return Err(Box::new(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = session.open()?;
|
||||||
|
state.process_events(&events);
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
|
||||||
|
let mut interrupted = false;
|
||||||
|
// Keep this in lockstep with run_stream: send due probes, drain available
|
||||||
|
// replies, poll timeouts, sleep toward the next absolute send deadline,
|
||||||
|
// then perform the same final drain, timeout poll, and close sequence.
|
||||||
|
while session.should_continue(shutdown_requested) {
|
||||||
|
if handle_input(&mut state, shutdown_requested)? {
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
}
|
||||||
|
if state.quit_requested {
|
||||||
|
interrupted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = session.step(shutdown_requested)?;
|
||||||
|
state.process_events(&events);
|
||||||
|
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
interrupted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
||||||
|
wait_for_tui_activity(
|
||||||
|
session.next_send_deadline(),
|
||||||
|
&mut next_render,
|
||||||
|
&mut state,
|
||||||
|
&mut terminal,
|
||||||
|
shutdown_requested,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
|
|
||||||
|
if interrupted {
|
||||||
|
state.set_status(TuiStatus::Interrupted);
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.should_drain_final(interrupted) {
|
||||||
|
state.set_status(TuiStatus::Draining);
|
||||||
|
let mut drain_render = Instant::now();
|
||||||
|
session.drain_final(|events| {
|
||||||
|
state.process_events(events);
|
||||||
|
let _ = render_if_due(&mut terminal, &state, &mut drain_render, false);
|
||||||
|
})?;
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = session.poll_timeouts()?;
|
||||||
|
state.process_events(&events);
|
||||||
|
|
||||||
|
state.set_status(TuiStatus::Closing);
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
|
||||||
|
let events = session.close()?;
|
||||||
|
state.process_events(&events);
|
||||||
|
state.set_status(TuiStatus::Complete);
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_group_tui(
|
||||||
|
args: TuiArgs,
|
||||||
|
targets: Vec<ResolvedTuiTarget>,
|
||||||
|
terminal: &mut TuiTerminal,
|
||||||
|
state: &mut TuiState,
|
||||||
|
next_render: &mut Instant,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut config = args.to_client_config();
|
||||||
|
let managed_targets = targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.managed.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let expected_target_count = managed_targets.len();
|
||||||
|
if let Some(first) = managed_targets.first() {
|
||||||
|
config.server_addr = first.remote.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let group_config = ManagedClientGroupConfig {
|
||||||
|
client: config,
|
||||||
|
pacing: args.pacing.into(),
|
||||||
|
};
|
||||||
|
let (session, events) = ManagedClientGroup::start_with_subscription(
|
||||||
|
group_config,
|
||||||
|
managed_targets,
|
||||||
|
SubscriberConfig {
|
||||||
|
capacity: 16_384,
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut interrupted = false;
|
||||||
|
let mut terminal_targets = std::collections::HashSet::new();
|
||||||
|
let mut saw_target_event = false;
|
||||||
|
let mut last_event_at = Instant::now();
|
||||||
|
|
||||||
|
let exit = loop {
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
interrupted = true;
|
||||||
|
session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if handle_input(state, shutdown_requested)? {
|
||||||
|
render_if_due(terminal, state, next_render, true)?;
|
||||||
|
}
|
||||||
|
if state.quit_requested {
|
||||||
|
interrupted = true;
|
||||||
|
session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(Some(target_event)) => {
|
||||||
|
saw_target_event = true;
|
||||||
|
last_event_at = Instant::now();
|
||||||
|
if is_terminal_target_event(&target_event.event) {
|
||||||
|
terminal_targets.insert(target_event.target.as_str().to_owned());
|
||||||
|
}
|
||||||
|
state.process_target_event(&target_event);
|
||||||
|
render_if_due(terminal, state, next_render, false)?;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
if interrupted {
|
||||||
|
break GroupLoopExit::Interrupted;
|
||||||
|
}
|
||||||
|
if terminal_targets.len() >= expected_target_count {
|
||||||
|
break GroupLoopExit::AllTargetsTerminal;
|
||||||
|
}
|
||||||
|
if should_join_group_after_idle(&args, saw_target_event, last_event_at) {
|
||||||
|
break GroupLoopExit::IdleGraceElapsed;
|
||||||
|
}
|
||||||
|
wait_for_tui_activity(None, next_render, state, terminal, shutdown_requested)?;
|
||||||
|
thread::sleep(IDLE_SLEEP);
|
||||||
|
}
|
||||||
|
Err(EventSubscriptionError::Disconnected) => {
|
||||||
|
break GroupLoopExit::SubscriptionDisconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if exit.should_stop_before_join() {
|
||||||
|
session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if interrupted {
|
||||||
|
state.set_status(TuiStatus::Interrupted);
|
||||||
|
render_if_due(terminal, state, next_render, true)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_status(TuiStatus::Closing);
|
||||||
|
render_if_due(terminal, state, next_render, true)?;
|
||||||
|
|
||||||
|
let outcome = session.join()?;
|
||||||
|
while let Ok(Some(target_event)) = events.try_recv() {
|
||||||
|
state.process_target_event(&target_event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if outcome.end_reason == ManagedGroupEndReason::Cancelled && !interrupted {
|
||||||
|
return match exit {
|
||||||
|
GroupLoopExit::IdleGraceElapsed => {
|
||||||
|
Err("managed client group stayed idle before all targets completed".into())
|
||||||
|
}
|
||||||
|
GroupLoopExit::SubscriptionDisconnected => {
|
||||||
|
Err("managed client group event subscription disconnected before completion".into())
|
||||||
|
}
|
||||||
|
GroupLoopExit::Interrupted | GroupLoopExit::AllTargetsTerminal => {
|
||||||
|
Err("managed client group was cancelled".into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_status(TuiStatus::Complete);
|
||||||
|
render_if_due(terminal, state, next_render, true)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GroupLoopExit {
|
||||||
|
Interrupted,
|
||||||
|
AllTargetsTerminal,
|
||||||
|
IdleGraceElapsed,
|
||||||
|
SubscriptionDisconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupLoopExit {
|
||||||
|
fn should_stop_before_join(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Interrupted | Self::IdleGraceElapsed | Self::SubscriptionDisconnected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_terminal_target_event(event: &ClientEvent) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
ClientEvent::SessionClosed { .. } | ClientEvent::NoTestCompleted { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimated_group_completion_grace(args: &TuiArgs) -> Duration {
|
||||||
|
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
|
||||||
|
open_timeout
|
||||||
|
.saturating_add(args.duration)
|
||||||
|
.saturating_add(GROUP_COMPLETION_GRACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_join_group_after_idle(
|
||||||
|
args: &TuiArgs,
|
||||||
|
saw_target_event: bool,
|
||||||
|
last_event_at: Instant,
|
||||||
|
) -> bool {
|
||||||
|
if args.is_continuous() && saw_target_event {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_event_at.elapsed() > estimated_group_completion_grace(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
||||||
|
let mut force_render = false;
|
||||||
|
while event::poll(Duration::ZERO)? {
|
||||||
|
let Event::Key(key) = event::read()? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.kind == KeyEventKind::Release {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
state.quit_requested = true;
|
||||||
|
shutdown_requested.store(true, Ordering::Relaxed);
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
state.quit_requested = true;
|
||||||
|
shutdown_requested.store(true, Ordering::Relaxed);
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
state.clear_visible_history();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('p') => {
|
||||||
|
state.toggle_pause();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('g') => {
|
||||||
|
state.toggle_view();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('m') => {
|
||||||
|
state.cycle_graph_metric();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
state.pan_graph_left();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
state.pan_graph_right();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
state.pan_graph_page_left();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
state.pan_graph_page_right();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
state.jump_graph_oldest();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
state.jump_graph_live();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
|
state.zoom_graph_in();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('-') => {
|
||||||
|
state.zoom_graph_out();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('0') => {
|
||||||
|
state.reset_graph_window();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(force_render)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_if_due(
|
||||||
|
terminal: &mut TuiTerminal,
|
||||||
|
state: &TuiState,
|
||||||
|
next_render: &mut Instant,
|
||||||
|
force: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let now = Instant::now();
|
||||||
|
if !should_render(now, *next_render, state.paused, force) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
terminal.draw(state)?;
|
||||||
|
*next_render = now + RENDER_INTERVAL;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_tui_activity(
|
||||||
|
next_send_deadline: Option<Instant>,
|
||||||
|
next_render: &mut Instant,
|
||||||
|
state: &mut TuiState,
|
||||||
|
terminal: &mut TuiTerminal,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
|
||||||
|
if wait_for.is_zero() || !event::poll(wait_for)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if handle_input(state, shutdown_requested)? {
|
||||||
|
render_if_due(terminal, state, next_render, true)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_wait_duration(
|
||||||
|
next_send_deadline: Option<Instant>,
|
||||||
|
next_render: Instant,
|
||||||
|
paused: bool,
|
||||||
|
) -> Duration {
|
||||||
|
let now = Instant::now();
|
||||||
|
let send_wait = next_send_deadline
|
||||||
|
.map(|deadline| deadline.saturating_duration_since(now))
|
||||||
|
.unwrap_or(IDLE_SLEEP);
|
||||||
|
let render_wait = if paused {
|
||||||
|
send_wait
|
||||||
|
} else {
|
||||||
|
next_render.saturating_duration_since(now)
|
||||||
|
};
|
||||||
|
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> TuiArgs {
|
||||||
|
let mut argv = vec!["irtt-tui"];
|
||||||
|
argv.extend_from_slice(args);
|
||||||
|
TuiArgs::try_parse_from(argv).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_multi_target_idle_does_not_stop_after_events() {
|
||||||
|
let args = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
|
||||||
|
let old_event_at = Instant::now() - estimated_group_completion_grace(&args) - IDLE_SLEEP;
|
||||||
|
|
||||||
|
assert!(!should_join_group_after_idle(&args, true, old_event_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finite_or_unopened_group_can_leave_after_idle_grace() {
|
||||||
|
let finite = parse(&["--duration", "1s", "127.0.0.1:2112", "127.0.0.2:2112"]);
|
||||||
|
let finite_old_event_at =
|
||||||
|
Instant::now() - estimated_group_completion_grace(&finite) - IDLE_SLEEP;
|
||||||
|
assert!(should_join_group_after_idle(
|
||||||
|
&finite,
|
||||||
|
true,
|
||||||
|
finite_old_event_at
|
||||||
|
));
|
||||||
|
|
||||||
|
let continuous = parse(&["127.0.0.1:2112", "127.0.0.2:2112"]);
|
||||||
|
let unopened_old_event_at =
|
||||||
|
Instant::now() - estimated_group_completion_grace(&continuous) - IDLE_SLEEP;
|
||||||
|
assert!(should_join_group_after_idle(
|
||||||
|
&continuous,
|
||||||
|
false,
|
||||||
|
unopened_old_event_at
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protective_group_exits_stop_before_joining() {
|
||||||
|
assert!(GroupLoopExit::Interrupted.should_stop_before_join());
|
||||||
|
assert!(GroupLoopExit::IdleGraceElapsed.should_stop_before_join());
|
||||||
|
assert!(GroupLoopExit::SubscriptionDisconnected.should_stop_before_join());
|
||||||
|
assert!(!GroupLoopExit::AllTargetsTerminal.should_stop_before_join());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
|||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
ffi::OsString,
|
||||||
|
process::ExitCode,
|
||||||
|
sync::{atomic::AtomicBool, Arc},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
|
||||||
|
signal::install_signal_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn main() -> ExitCode {
|
||||||
|
match run_from_env() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("irtt-rs: {err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let argv: Vec<OsString> = env::args_os().collect();
|
||||||
|
let (requested, argv) = match dispatch_from_argv(argv)? {
|
||||||
|
AppletDispatch::Run { applet, argv } => (applet, argv),
|
||||||
|
AppletDispatch::Help(help) => {
|
||||||
|
print!("{help}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||||
|
install_signal_handler(Arc::clone(&shutdown_requested))
|
||||||
|
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||||
|
|
||||||
|
match requested {
|
||||||
|
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
||||||
|
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
||||||
|
RequestedApplet::Server => Err("server applet is not available in this build".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
fn run_client_applet(
|
||||||
|
argv: Vec<OsString>,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||||
|
crate::cmd::client::run_stream(args, shutdown_requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "client"))]
|
||||||
|
fn run_client_applet(
|
||||||
|
_argv: Vec<OsString>,
|
||||||
|
_shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Err("client applet is not available; rebuild with the client feature".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
fn run_tui_applet(
|
||||||
|
argv: Vec<OsString>,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||||
|
crate::cmd::tui::run_tui(args, shutdown_requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
fn run_tui_applet(
|
||||||
|
_argv: Vec<OsString>,
|
||||||
|
_shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||||
|
}
|
||||||
+6
-1472
File diff suppressed because it is too large.
Load diff
@@ -1,479 +0,0 @@
|
|||||||
use std::{
|
|
||||||
io::{self, Write},
|
|
||||||
process::ExitCode,
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use irtt_cli::{
|
|
||||||
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
|
|
||||||
};
|
|
||||||
use irtt_client::{Client, ClientEvent, 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()) {
|
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("irtt-rs: {err}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
|
||||||
ctrlc::set_handler(move || {
|
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mode = args.output;
|
|
||||||
let continuous = args.is_continuous();
|
|
||||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
|
||||||
let mut output = EventOutput {
|
|
||||||
mode,
|
|
||||||
human_options: HumanOutputOptions {
|
|
||||||
verbose: args.verbose,
|
|
||||||
},
|
|
||||||
print_finite_summary: !continuous,
|
|
||||||
out: &mut stdout,
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut client = Client::connect(args.to_client_config())?;
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let open = client.open()?;
|
|
||||||
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>,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested)
|
|
||||||
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
|
||||||
!continuous || interrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
|
||||||
match outcome {
|
|
||||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_final_replies<W: Write>(
|
|
||||||
client: &mut Client,
|
|
||||||
output: &mut EventOutput<'_, W>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
|
|
||||||
loop {
|
|
||||||
let mut printed = false;
|
|
||||||
|
|
||||||
let events = client.recv_available(RECV_BUDGET)?;
|
|
||||||
printed |= !events.is_empty();
|
|
||||||
output.print_events(&events)?;
|
|
||||||
|
|
||||||
let events = client.poll_timeouts()?;
|
|
||||||
printed |= !events.is_empty();
|
|
||||||
output.print_events(&events)?;
|
|
||||||
|
|
||||||
if client.is_run_complete() || Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !printed {
|
|
||||||
thread::sleep(IDLE_SLEEP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sleep_until_next_send(deadline: Option<Instant>) {
|
|
||||||
let sleep_for = match deadline {
|
|
||||||
Some(deadline) => deadline
|
|
||||||
.saturating_duration_since(Instant::now())
|
|
||||||
.min(MAX_SLEEP),
|
|
||||||
None => Duration::from_millis(1),
|
|
||||||
};
|
|
||||||
if !sleep_for.is_zero() {
|
|
||||||
thread::sleep(sleep_for);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EventOutput<'a, W: Write> {
|
|
||||||
mode: irtt_cli::OutputMode,
|
|
||||||
human_options: HumanOutputOptions,
|
|
||||||
print_finite_summary: bool,
|
|
||||||
out: &'a mut W,
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
stats: &'a mut StatsCollector,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<W: Write> EventOutput<'_, W> {
|
|
||||||
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
|
|
||||||
for event in events {
|
|
||||||
self.print_event(event)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
let stats_update = self.stats.process(event);
|
|
||||||
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
let stats_update = irtt_cli::HumanEventStats::default();
|
|
||||||
|
|
||||||
let line =
|
|
||||||
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
|
|
||||||
Some(format_human_event_with_options(
|
|
||||||
event,
|
|
||||||
Some(stats_update.into()),
|
|
||||||
self.human_options,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
format_event(event, self.mode)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(line) = line {
|
|
||||||
writeln!(self.out, "{line}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_summary(&mut self) -> io::Result<()> {
|
|
||||||
if !self.print_finite_summary || !self.mode.prints_summary() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
{
|
|
||||||
write!(
|
|
||||||
self.out,
|
|
||||||
"{}",
|
|
||||||
irtt_cli::summary::format_summary_with_options(
|
|
||||||
&self.stats.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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,15 @@
|
|||||||
|
pub mod args;
|
||||||
|
pub mod config;
|
||||||
|
pub mod session;
|
||||||
|
pub mod targets;
|
||||||
|
|
||||||
|
pub use args::{
|
||||||
|
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
||||||
|
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||||
|
};
|
||||||
|
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
||||||
|
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
|
||||||
|
pub use targets::{
|
||||||
|
parse_labelled_target, resolved_managed_targets, target_specs, GroupPacingArg,
|
||||||
|
LabelledTargetArg, ResolvedTarget, TargetSpec,
|
||||||
|
};
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
use std::{
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
|
||||||
|
|
||||||
|
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
|
||||||
|
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
|
||||||
|
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
|
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
pub struct ClientSession {
|
||||||
|
client: Client,
|
||||||
|
continuous: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientSession {
|
||||||
|
pub fn connect(
|
||||||
|
config: ClientConfig,
|
||||||
|
continuous: bool,
|
||||||
|
) -> Result<Self, irtt_client::ClientError> {
|
||||||
|
Ok(Self {
|
||||||
|
client: Client::connect(config)?,
|
||||||
|
continuous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
||||||
|
Ok(vec![open_event(&self.client.open()?).clone()])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step(
|
||||||
|
&mut self,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
|
||||||
|
events.extend(self.client.send_probe()?);
|
||||||
|
}
|
||||||
|
events.extend(self.client.recv_available(RECV_BUDGET)?);
|
||||||
|
if self.client.is_peer_closed() {
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
events.extend(self.client.poll_timeouts()?);
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
|
||||||
|
!is_shutdown_requested(shutdown_requested)
|
||||||
|
&& !self.client.is_peer_closed()
|
||||||
|
&& (self.continuous || self.client.next_send_deadline().is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_drain_final(&self, interrupted: bool) -> bool {
|
||||||
|
should_drain_final(self.continuous, interrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
|
||||||
|
where
|
||||||
|
F: FnMut(&[ClientEvent]),
|
||||||
|
{
|
||||||
|
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
|
||||||
|
loop {
|
||||||
|
if self.client.is_peer_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut received = false;
|
||||||
|
|
||||||
|
let events = self.client.recv_available(RECV_BUDGET)?;
|
||||||
|
received |= !events.is_empty();
|
||||||
|
on_events(&events);
|
||||||
|
|
||||||
|
if self.client.is_peer_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = self.client.poll_timeouts()?;
|
||||||
|
received |= !events.is_empty();
|
||||||
|
on_events(&events);
|
||||||
|
|
||||||
|
if self.client.is_run_complete() || Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !received {
|
||||||
|
thread::sleep(IDLE_SLEEP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
||||||
|
if self.client.is_peer_closed() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
self.client.poll_timeouts()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
||||||
|
if self.client.is_peer_closed() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
self.client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||||
|
self.client.next_send_deadline()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn probe_timeout(&self) -> Duration {
|
||||||
|
self.client.probe_timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sleep_until_next_send(&self) {
|
||||||
|
let sleep_for = match self.next_send_deadline() {
|
||||||
|
Some(deadline) => deadline
|
||||||
|
.saturating_duration_since(Instant::now())
|
||||||
|
.min(MAX_SLEEP),
|
||||||
|
None => Duration::from_millis(1),
|
||||||
|
};
|
||||||
|
if !sleep_for.is_zero() {
|
||||||
|
thread::sleep(sleep_for);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
||||||
|
shutdown_requested.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
||||||
|
!continuous || interrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||||
|
!continuous || interrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
|
||||||
|
probe_timeout.min(MAX_FINAL_DRAIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
||||||
|
!is_shutdown_requested(shutdown_requested)
|
||||||
|
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
||||||
|
match outcome {
|
||||||
|
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use irtt_proto::{
|
||||||
|
echo_packet_len,
|
||||||
|
flags::{self, FLAG_OPEN, FLAG_REPLY},
|
||||||
|
layout::PacketLayout,
|
||||||
|
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
net::{SocketAddr, UdpSocket},
|
||||||
|
thread::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||||
|
|
||||||
|
struct FakeServer {
|
||||||
|
addr: SocketAddr,
|
||||||
|
done: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeServer {
|
||||||
|
fn join(self) {
|
||||||
|
self.done.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn final_drain_uses_capped_probe_timeout() {
|
||||||
|
assert_eq!(
|
||||||
|
final_drain_duration(Duration::from_secs(4)),
|
||||||
|
Duration::from_secs(4)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
final_drain_duration(Duration::from_secs(60)),
|
||||||
|
Duration::from_secs(30)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_drain_final_for_finite_or_interrupted_runs() {
|
||||||
|
assert!(should_drain_final(false, false));
|
||||||
|
assert!(should_drain_final(false, true));
|
||||||
|
assert!(should_drain_final(true, true));
|
||||||
|
assert!(!should_drain_final(true, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_close_followed_by_session_cleanup_is_successful() {
|
||||||
|
let params = test_params(None, Duration::from_millis(10));
|
||||||
|
let server = start_peer_close_server(params);
|
||||||
|
let mut session = ClientSession::connect(config(server.addr, None), true).unwrap();
|
||||||
|
let shutdown_requested = AtomicBool::new(false);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
session.open().unwrap().as_slice(),
|
||||||
|
[ClientEvent::SessionStarted { .. }]
|
||||||
|
));
|
||||||
|
|
||||||
|
let events = session.step(&shutdown_requested).unwrap();
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
||||||
|
assert!(!session.should_continue(&shutdown_requested));
|
||||||
|
|
||||||
|
session.drain_final(|_| {}).unwrap();
|
||||||
|
assert!(session.poll_timeouts().unwrap().is_empty());
|
||||||
|
assert!(session.close().unwrap().is_empty());
|
||||||
|
server.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(addr: SocketAddr, duration: Option<Duration>) -> ClientConfig {
|
||||||
|
ClientConfig {
|
||||||
|
server_addr: addr.to_string(),
|
||||||
|
duration,
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
open_timeouts: vec![Duration::from_millis(200)],
|
||||||
|
probe_timeout: Duration::from_millis(20),
|
||||||
|
socket_config: irtt_client::SocketConfig {
|
||||||
|
recv_timeout: Some(Duration::from_millis(200)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..ClientConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
|
||||||
|
Params {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
duration_ns: duration.map_or(0, test_duration_ns_i64),
|
||||||
|
interval_ns: test_duration_ns_i64(interval),
|
||||||
|
length: 0,
|
||||||
|
received_stats: ReceivedStats::Both,
|
||||||
|
stamp_at: StampAt::Both,
|
||||||
|
clock: Clock::Both,
|
||||||
|
dscp: 0,
|
||||||
|
server_fill: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_duration_ns_i64(duration: Duration) -> i64 {
|
||||||
|
i64::try_from(duration.as_nanos()).expect("test duration fits i64 nanoseconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_peer_close_server(params: Params) -> FakeServer {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = socket.local_addr().unwrap();
|
||||||
|
let done = thread::spawn(move || {
|
||||||
|
let (_, peer) = recv_request(&socket);
|
||||||
|
socket
|
||||||
|
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (packet, peer) = recv_request(&socket);
|
||||||
|
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||||
|
socket
|
||||||
|
.send_to(
|
||||||
|
&echo_reply_packet(
|
||||||
|
TOKEN,
|
||||||
|
seq,
|
||||||
|
¶ms,
|
||||||
|
&TimestampFields::default(),
|
||||||
|
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||||
|
),
|
||||||
|
peer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||||
|
.unwrap();
|
||||||
|
if let Some((packet, _)) = recv_request_timeout(&socket) {
|
||||||
|
panic!(
|
||||||
|
"session cleanup must not send any packet after peer close; flags={:?} len={}",
|
||||||
|
packet.get(3).copied(),
|
||||||
|
packet.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeServer { addr, done }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
||||||
|
let mut buf = [0_u8; 2048];
|
||||||
|
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
||||||
|
(buf[..size].to_vec(), peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recv_request_timeout(socket: &UdpSocket) -> Option<(Vec<u8>, SocketAddr)> {
|
||||||
|
let mut buf = [0_u8; 2048];
|
||||||
|
socket
|
||||||
|
.recv_from(&mut buf)
|
||||||
|
.ok()
|
||||||
|
.map(|(size, peer)| (buf[..size].to_vec(), peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
|
||||||
|
let mut packet = Vec::new();
|
||||||
|
packet.extend_from_slice(&MAGIC);
|
||||||
|
packet.push(flags);
|
||||||
|
packet.extend_from_slice(&token.to_le_bytes());
|
||||||
|
packet.extend_from_slice(¶ms.encode());
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_reply_packet(
|
||||||
|
token: u64,
|
||||||
|
seq: u32,
|
||||||
|
params: &Params,
|
||||||
|
timestamps: &TimestampFields,
|
||||||
|
flags: u8,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let layout = PacketLayout::echo(false, params);
|
||||||
|
let packet_len = echo_packet_len(false, params)
|
||||||
|
.expect("session test params must have a non-negative packet length");
|
||||||
|
let mut packet = Vec::with_capacity(packet_len);
|
||||||
|
|
||||||
|
packet.extend_from_slice(&MAGIC);
|
||||||
|
packet.push(flags);
|
||||||
|
packet.extend_from_slice(&token.to_le_bytes());
|
||||||
|
packet.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
|
||||||
|
if layout.recv_count {
|
||||||
|
packet.extend_from_slice(&42_u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_window {
|
||||||
|
packet.extend_from_slice(&0_u64.to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_wall {
|
||||||
|
packet.extend_from_slice(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_mono {
|
||||||
|
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_wall {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_mono {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_wall {
|
||||||
|
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_mono {
|
||||||
|
packet.extend_from_slice(×tamps.send_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
packet.resize(packet_len, 0);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use std::{collections::HashSet, net::ToSocketAddrs};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use irtt_client::{ClientConfig, ManagedGroupPacing, ManagedTargetConfig, TargetId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LabelledTargetArg {
|
||||||
|
pub label: String,
|
||||||
|
pub addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TargetSpec {
|
||||||
|
pub label: String,
|
||||||
|
pub addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedTarget {
|
||||||
|
pub label: String,
|
||||||
|
pub managed: ManagedTargetConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum GroupPacingArg {
|
||||||
|
Staggered,
|
||||||
|
Burst,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GroupPacingArg> for ManagedGroupPacing {
|
||||||
|
fn from(value: GroupPacingArg) -> Self {
|
||||||
|
match value {
|
||||||
|
GroupPacingArg::Staggered => Self::Staggered,
|
||||||
|
GroupPacingArg::Burst => Self::Burst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_labelled_target(input: &str) -> Result<LabelledTargetArg, String> {
|
||||||
|
let (label, addr) = input
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| "target must use LABEL=TARGET syntax".to_owned())?;
|
||||||
|
if label.is_empty() {
|
||||||
|
return Err("target label must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
if addr.is_empty() {
|
||||||
|
return Err("target address must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
Ok(LabelledTargetArg {
|
||||||
|
label: label.to_owned(),
|
||||||
|
addr: addr.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_specs(
|
||||||
|
positional_targets: &[String],
|
||||||
|
labelled_targets: &[LabelledTargetArg],
|
||||||
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
let mut positional_counts = std::collections::HashMap::<&str, usize>::new();
|
||||||
|
for target in positional_targets {
|
||||||
|
let count = positional_counts.entry(target.as_str()).or_default();
|
||||||
|
*count += 1;
|
||||||
|
let label = if *count == 1 {
|
||||||
|
target.clone()
|
||||||
|
} else {
|
||||||
|
format!("{target}#{}", *count)
|
||||||
|
};
|
||||||
|
specs.push(TargetSpec {
|
||||||
|
label,
|
||||||
|
addr: target.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in labelled_targets {
|
||||||
|
specs.push(TargetSpec {
|
||||||
|
label: target.label.clone(),
|
||||||
|
addr: target.addr.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if specs.is_empty() {
|
||||||
|
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut labels = HashSet::new();
|
||||||
|
for spec in &specs {
|
||||||
|
if !labels.insert(spec.label.clone()) {
|
||||||
|
return Err(format!("duplicate target label {:?}", spec.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_managed_targets(
|
||||||
|
specs: Vec<TargetSpec>,
|
||||||
|
config: &ClientConfig,
|
||||||
|
) -> Result<Vec<ResolvedTarget>, String> {
|
||||||
|
let mut remotes = HashSet::new();
|
||||||
|
let mut targets = Vec::with_capacity(specs.len());
|
||||||
|
for spec in specs {
|
||||||
|
let remote = resolve_target(&spec.addr, config).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to resolve target {} ({:?}): {err}",
|
||||||
|
spec.label, spec.addr
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !remotes.insert(remote) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate resolved target address {remote} for label {}",
|
||||||
|
spec.label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
targets.push(ResolvedTarget {
|
||||||
|
label: spec.label.clone(),
|
||||||
|
managed: ManagedTargetConfig {
|
||||||
|
id: TargetId::from(spec.label),
|
||||||
|
remote,
|
||||||
|
auth: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_target(addr: &str, config: &ClientConfig) -> Result<std::net::SocketAddr, String> {
|
||||||
|
let normalized = normalize_target_addr(addr);
|
||||||
|
let mut addrs = normalized
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|_| format!("failed to resolve address {normalized:?}"))?;
|
||||||
|
addrs
|
||||||
|
.find(|addr| {
|
||||||
|
(!config.socket_config.ipv4_only || addr.is_ipv4())
|
||||||
|
&& (!config.socket_config.ipv6_only || addr.is_ipv6())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("failed to resolve address {normalized:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_target_addr(addr: &str) -> String {
|
||||||
|
const DEFAULT_PORT: u16 = 2112;
|
||||||
|
if addr.parse::<std::net::SocketAddr>().is_ok() {
|
||||||
|
return addr.to_owned();
|
||||||
|
}
|
||||||
|
if addr.starts_with('[') && addr.ends_with(']') {
|
||||||
|
return format!("{addr}:{DEFAULT_PORT}");
|
||||||
|
}
|
||||||
|
if addr.starts_with('[') {
|
||||||
|
return addr.to_owned();
|
||||||
|
}
|
||||||
|
if addr.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||||
|
return format!("[{addr}]:{DEFAULT_PORT}");
|
||||||
|
}
|
||||||
|
if addr
|
||||||
|
.rsplit_once(':')
|
||||||
|
.is_some_and(|(_, port)| port.parse::<u16>().is_ok())
|
||||||
|
{
|
||||||
|
return addr.to_owned();
|
||||||
|
}
|
||||||
|
format!("{addr}:{DEFAULT_PORT}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_normalization_matches_single_target_forms() {
|
||||||
|
assert_eq!(normalize_target_addr("127.0.0.1"), "127.0.0.1:2112");
|
||||||
|
assert_eq!(normalize_target_addr("127.0.0.1:1234"), "127.0.0.1:1234");
|
||||||
|
assert_eq!(normalize_target_addr("example.com"), "example.com:2112");
|
||||||
|
assert_eq!(
|
||||||
|
normalize_target_addr("example.com:1234"),
|
||||||
|
"example.com:1234"
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_target_addr("[::1]"), "[::1]:2112");
|
||||||
|
assert_eq!(normalize_target_addr("[::1]:1234"), "[::1]:1234");
|
||||||
|
assert_eq!(normalize_target_addr("::1"), "[::1]:2112");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_resolution_respects_family_filters() {
|
||||||
|
let config = ClientConfig::default();
|
||||||
|
assert_eq!(
|
||||||
|
resolve_target("127.0.0.1", &config).unwrap(),
|
||||||
|
"127.0.0.1:2112".parse().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_target("[::1]", &config).unwrap(),
|
||||||
|
"[::1]:2112".parse().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_target("::1", &config).unwrap(),
|
||||||
|
"[::1]:2112".parse().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ipv4_only = ClientConfig::default();
|
||||||
|
ipv4_only.socket_config.ipv4_only = true;
|
||||||
|
assert!(resolve_target("127.0.0.1", &ipv4_only).unwrap().is_ipv4());
|
||||||
|
assert!(resolve_target("::1", &ipv4_only).is_err());
|
||||||
|
|
||||||
|
let mut ipv6_only = ClientConfig::default();
|
||||||
|
ipv6_only.socket_config.ipv6_only = true;
|
||||||
|
assert!(resolve_target("::1", &ipv6_only).unwrap().is_ipv6());
|
||||||
|
assert!(resolve_target("127.0.0.1", &ipv6_only).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_specs_suffix_repeated_positionals_and_reject_duplicate_labels() {
|
||||||
|
let positionals = vec!["host-a:2112".to_owned(), "host-a:2112".to_owned()];
|
||||||
|
let specs = target_specs(&positionals, &[]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(specs[0].label, "host-a:2112");
|
||||||
|
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||||
|
|
||||||
|
let labels = vec![LabelledTargetArg {
|
||||||
|
label: "host-a:2112".to_owned(),
|
||||||
|
addr: "host-b:2112".to_owned(),
|
||||||
|
}];
|
||||||
|
let err = target_specs(&positionals[..1], &labels).unwrap_err();
|
||||||
|
assert!(err.contains("duplicate target label"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_resolved_target_addresses_are_rejected() {
|
||||||
|
let specs = vec![
|
||||||
|
TargetSpec {
|
||||||
|
label: "a".to_owned(),
|
||||||
|
addr: "127.0.0.1:2112".to_owned(),
|
||||||
|
},
|
||||||
|
TargetSpec {
|
||||||
|
label: "b".to_owned(),
|
||||||
|
addr: "127.0.0.1".to_owned(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let err = resolved_managed_targets(specs, &ClientConfig::default()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("duplicate resolved target address 127.0.0.1:2112"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#[cfg(feature = "client-runtime")]
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
|
fn irtt_rs() -> Command {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn irtt_rs_with_arg0(arg0: &str) -> Command {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
let mut command = irtt_rs();
|
||||||
|
command.arg0(arg0);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_text(output: &Output) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||||
|
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_help_shows_dispatcher_help() {
|
||||||
|
let output = irtt_rs().arg("--help").output().unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(output.status.success(), "{text}");
|
||||||
|
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
|
||||||
|
assert!(text.contains("client"));
|
||||||
|
assert!(text.contains("tui"));
|
||||||
|
assert!(text.contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_without_applet_errors() {
|
||||||
|
let output = irtt_rs().output().unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(text.contains("choose an applet"), "{text}");
|
||||||
|
assert!(text.contains("client"));
|
||||||
|
assert!(text.contains("tui"));
|
||||||
|
assert!(text.contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
#[test]
|
||||||
|
fn canonical_client_subcommand_dispatches_to_client() {
|
||||||
|
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(output.status.success(), "{text}");
|
||||||
|
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||||
|
assert!(text.contains("--format <FORMAT>"));
|
||||||
|
assert!(text.contains("--columns <COLUMNS>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "client"))]
|
||||||
|
#[test]
|
||||||
|
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
|
||||||
|
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(
|
||||||
|
text.contains("client applet is not available; rebuild with the client feature"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
#[test]
|
||||||
|
fn client_applet_name_dispatches_to_client() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-cli")
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(output.status.success(), "{text}");
|
||||||
|
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||||
|
assert!(text.contains("--format <FORMAT>"));
|
||||||
|
assert!(text.contains("--columns <COLUMNS>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(feature = "client")))]
|
||||||
|
#[test]
|
||||||
|
fn client_applet_name_reports_unavailable_when_disabled() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-cli")
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(
|
||||||
|
text.contains("client applet is not available; rebuild with the client feature"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "tui"))]
|
||||||
|
#[test]
|
||||||
|
fn tui_applet_name_dispatches_to_tui_when_enabled() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-tui")
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(output.status.success(), "{text}");
|
||||||
|
assert!(text.contains("Minimal IRTT-compatible TUI client"));
|
||||||
|
assert!(text.contains("--duration <DURATION>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(feature = "tui")))]
|
||||||
|
#[test]
|
||||||
|
fn tui_applet_name_reports_unavailable_when_disabled() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-tui")
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(text.contains("TUI applet is not available"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn server_applet_name_reports_unavailable() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-server")
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(text.contains("server applet is not available"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn unknown_irtt_applet_name_errors() {
|
||||||
|
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
|
||||||
|
let text = output_text(&output);
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
|
||||||
|
assert!(text.contains("irtt-cli"));
|
||||||
|
assert!(text.contains("irtt-tui"));
|
||||||
|
assert!(text.contains("irtt-server"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
#![cfg(feature = "client")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
net::{SocketAddr, UdpSocket},
|
||||||
|
process::Command,
|
||||||
|
thread,
|
||||||
|
thread::JoinHandle,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_proto::{
|
||||||
|
echo_packet_len,
|
||||||
|
flags::{FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY},
|
||||||
|
layout::PacketLayout,
|
||||||
|
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||||
|
|
||||||
|
struct FakeServer {
|
||||||
|
addr: SocketAddr,
|
||||||
|
done: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeServer {
|
||||||
|
fn join(self) {
|
||||||
|
self.done.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_columns_succeeds_without_target() {
|
||||||
|
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
||||||
|
.arg("--list-columns")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("Available event columns:"), "{stdout}");
|
||||||
|
assert!(stdout.contains("target"), "{stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_target_default_table_omits_target_and_accepts_pacing() {
|
||||||
|
let server = start_echo_server(test_params(
|
||||||
|
Some(Duration::from_millis(30)),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
));
|
||||||
|
|
||||||
|
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
||||||
|
.args([
|
||||||
|
"--duration",
|
||||||
|
"30ms",
|
||||||
|
"--interval",
|
||||||
|
"10ms",
|
||||||
|
"--pacing",
|
||||||
|
"burst",
|
||||||
|
"--header",
|
||||||
|
"always",
|
||||||
|
&server.addr.to_string(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
server.join();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let header = stdout.lines().next().unwrap_or_default();
|
||||||
|
assert!(!header.split_whitespace().any(|column| column == "target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_target_custom_target_column_renders_positional_label() {
|
||||||
|
let server = start_echo_server(test_params(
|
||||||
|
Some(Duration::from_millis(30)),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
));
|
||||||
|
let target = server.addr.to_string();
|
||||||
|
|
||||||
|
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
||||||
|
.args([
|
||||||
|
"--duration",
|
||||||
|
"30ms",
|
||||||
|
"--interval",
|
||||||
|
"10ms",
|
||||||
|
"--format",
|
||||||
|
"csv",
|
||||||
|
"--columns",
|
||||||
|
"target,seq",
|
||||||
|
"--header",
|
||||||
|
"never",
|
||||||
|
&target,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
server.join();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with(&format!("{target},"))),
|
||||||
|
"{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_target_csv_emits_rows_for_both_labels() {
|
||||||
|
let params = test_params(Some(Duration::from_millis(40)), Duration::from_millis(10));
|
||||||
|
let a = start_echo_server(params.clone());
|
||||||
|
let b = start_echo_server(params);
|
||||||
|
|
||||||
|
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
|
||||||
|
.args([
|
||||||
|
"--duration",
|
||||||
|
"40ms",
|
||||||
|
"--interval",
|
||||||
|
"10ms",
|
||||||
|
"--format",
|
||||||
|
"csv",
|
||||||
|
"--columns",
|
||||||
|
"target,seq,effective_rtt_us",
|
||||||
|
"--header",
|
||||||
|
"never",
|
||||||
|
"--target",
|
||||||
|
&format!("a={}", a.addr),
|
||||||
|
"--target",
|
||||||
|
&format!("b={}", b.addr),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
a.join();
|
||||||
|
b.join();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status={:?}\nstderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.lines().any(|line| line.starts_with("a,")),
|
||||||
|
"{stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.lines().any(|line| line.starts_with("b,")),
|
||||||
|
"{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_echo_server(params: Params) -> FakeServer {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
let addr = socket.local_addr().unwrap();
|
||||||
|
let done = thread::spawn(move || {
|
||||||
|
let (_, peer) = recv_request(&socket);
|
||||||
|
socket
|
||||||
|
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (packet, peer) = recv_request(&socket);
|
||||||
|
let flags = packet[3];
|
||||||
|
if flags & FLAG_CLOSE != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||||
|
socket
|
||||||
|
.send_to(
|
||||||
|
&echo_reply_packet(
|
||||||
|
TOKEN,
|
||||||
|
seq,
|
||||||
|
¶ms,
|
||||||
|
&TimestampFields::default(),
|
||||||
|
FLAG_REPLY,
|
||||||
|
),
|
||||||
|
peer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeServer { addr, done }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
||||||
|
let mut buf = [0_u8; 2048];
|
||||||
|
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
||||||
|
(buf[..size].to_vec(), peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
|
||||||
|
let mut packet = Vec::new();
|
||||||
|
packet.extend_from_slice(&MAGIC);
|
||||||
|
packet.push(flags);
|
||||||
|
packet.extend_from_slice(&token.to_le_bytes());
|
||||||
|
packet.extend_from_slice(¶ms.encode());
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_reply_packet(
|
||||||
|
token: u64,
|
||||||
|
seq: u32,
|
||||||
|
params: &Params,
|
||||||
|
timestamps: &TimestampFields,
|
||||||
|
flags: u8,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let layout = PacketLayout::echo(false, params);
|
||||||
|
let packet_len = echo_packet_len(false, params).unwrap();
|
||||||
|
let mut packet = Vec::with_capacity(packet_len);
|
||||||
|
|
||||||
|
packet.extend_from_slice(&MAGIC);
|
||||||
|
packet.push(flags);
|
||||||
|
packet.extend_from_slice(&token.to_le_bytes());
|
||||||
|
packet.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
|
||||||
|
if layout.recv_count {
|
||||||
|
packet.extend_from_slice(&42_u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_window {
|
||||||
|
packet.extend_from_slice(&0_u64.to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_wall {
|
||||||
|
packet.extend_from_slice(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.recv_mono {
|
||||||
|
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_wall {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.midpoint_mono {
|
||||||
|
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_wall {
|
||||||
|
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
if layout.send_mono {
|
||||||
|
packet.extend_from_slice(×tamps.send_mono.unwrap_or(0).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
packet.resize(packet_len, 0);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
|
||||||
|
Params {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
duration_ns: duration.map_or(0, duration_ns_i64),
|
||||||
|
interval_ns: duration_ns_i64(interval),
|
||||||
|
length: 0,
|
||||||
|
received_stats: ReceivedStats::Both,
|
||||||
|
stamp_at: StampAt::Both,
|
||||||
|
clock: Clock::Both,
|
||||||
|
dscp: 0,
|
||||||
|
server_fill: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_ns_i64(duration: Duration) -> i64 {
|
||||||
|
i64::try_from(duration.as_nanos()).unwrap()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "IRTT-compatible Rust client library"
|
description = "IRTT-compatible Rust client library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -14,7 +14,7 @@ categories = ["network-programming"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
irtt-proto.workspace = true
|
irtt-proto.workspace = true
|
||||||
libc = { version = "0.2.186", optional = true }
|
libc = { version = "0.2.186", optional = true }
|
||||||
socket2 = { version = "0.6.3", features = ["all"] }
|
socket2 = { version = "0.6.4", features = ["all"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
+125
-786
File diff suppressed because it is too large.
Load diff
@@ -68,31 +68,6 @@ fn send_probe_fails_after_close() {
|
|||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_clears_timed_out_metadata() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
probe_timeout: Duration::from_millis(40),
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(50)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(60));
|
|
||||||
client.poll_timeouts().unwrap();
|
|
||||||
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
assert!(client.session.is_none());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||||
let params = default_params();
|
let params = default_params();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_from_config_accepts_udp_payload_length_boundary_values() {
|
fn params_from_config_accepts_boundary_values() {
|
||||||
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
||||||
let config = ClientConfig {
|
let config = ClientConfig {
|
||||||
length,
|
length,
|
||||||
@@ -62,18 +62,12 @@ fn params_from_config_accepts_udp_payload_length_boundary_values() {
|
|||||||
i64::from(length)
|
i64::from(length)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_oversized_udp_payload_length() {
|
|
||||||
let config = ClientConfig {
|
let config = ClientConfig {
|
||||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
dscp: 63,
|
||||||
..ClientConfig::default()
|
..ClientConfig::default()
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -86,97 +80,85 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_from_config_rejects_zero_finite_duration() {
|
fn params_from_config_rejects_invalid_values() {
|
||||||
let config = ClientConfig {
|
|
||||||
duration: Some(Duration::ZERO),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { reason })
|
|
||||||
if reason == "duration must be greater than zero; use None for continuous mode"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_zero_interval() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
interval: Duration::ZERO,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
|
|
||||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||||
let config = ClientConfig {
|
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||||
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
|
let cases = [
|
||||||
..ClientConfig::default()
|
(
|
||||||
};
|
"oversized UDP payload",
|
||||||
assert!(matches!(
|
ClientConfig {
|
||||||
params_from_config(&config),
|
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||||
Err(ClientError::InvalidConfig { reason })
|
..ClientConfig::default()
|
||||||
if reason == "duration is too large to encode as nanoseconds"
|
},
|
||||||
));
|
"packet length",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zero finite duration",
|
||||||
|
ClientConfig {
|
||||||
|
duration: Some(Duration::ZERO),
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"duration must be greater than zero; use None for continuous mode",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zero interval",
|
||||||
|
ClientConfig {
|
||||||
|
interval: Duration::ZERO,
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"interval must be greater than zero",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"duration nanosecond overflow",
|
||||||
|
ClientConfig {
|
||||||
|
duration: Some(too_large),
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"duration is too large to encode as nanoseconds",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"interval nanosecond overflow",
|
||||||
|
ClientConfig {
|
||||||
|
interval: too_large,
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"interval is too large to encode as nanoseconds",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"invalid DSCP codepoint",
|
||||||
|
ClientConfig {
|
||||||
|
dscp: 64,
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"dscp",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"empty server fill",
|
||||||
|
ClientConfig {
|
||||||
|
server_fill: Some("".to_owned()),
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"server_fill",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oversized server fill",
|
||||||
|
ClientConfig {
|
||||||
|
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
"server_fill",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
let config = ClientConfig {
|
for (name, config, expected_reason) in cases {
|
||||||
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
|
assert!(
|
||||||
..ClientConfig::default()
|
matches!(
|
||||||
};
|
params_from_config(&config),
|
||||||
assert!(matches!(
|
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
||||||
params_from_config(&config),
|
),
|
||||||
Err(ClientError::InvalidConfig { reason })
|
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
||||||
if reason == "interval is too large to encode as nanoseconds"
|
);
|
||||||
));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_accepts_max_dscp_codepoint() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 63,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_invalid_dscp_codepoint() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 64,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_empty_server_fill() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
server_fill: Some("".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_oversized_server_fill() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_open_close_succeeds_on_open_reply_close() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = no_test_server(params.clone(), 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
|
||||||
assert_eq!(negotiated.params, params);
|
|
||||||
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
|
|
||||||
assert!(matches!(client.close(), Err(ClientError::NotOpen)));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_test_success_validates_params() {
|
fn no_test_success_validates_params() {
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn address_resolution_connects_to_local_fake_server() {
|
|
||||||
let server = start_fake_server(|socket, tx| {
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
client.socket.send(b"ping").unwrap();
|
|
||||||
assert_eq!(server.rx.recv().unwrap(), b"ping");
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn successful_open_handshake() {
|
fn successful_open_handshake() {
|
||||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||||
@@ -20,7 +9,6 @@ fn successful_open_handshake() {
|
|||||||
|
|
||||||
let negotiated = assert_open_started(client.open().unwrap());
|
let negotiated = assert_open_started(client.open().unwrap());
|
||||||
assert_eq!(negotiated.params, params);
|
assert_eq!(negotiated.params, params);
|
||||||
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
|
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,27 +57,6 @@ fn open_timeout_after_all_timeouts() {
|
|||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_restores_configured_read_timeout_after_timeout() {
|
|
||||||
let server = timeout_server(Duration::from_millis(700));
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(200)],
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(450)),
|
|
||||||
..crate::SocketConfig::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let mut buf = [0_u8; 1];
|
|
||||||
assert!(client.socket.recv(&mut buf).is_err());
|
|
||||||
assert!(start.elapsed() >= Duration::from_millis(350));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_version_mismatch_fails() {
|
fn protocol_version_mismatch_fails() {
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,54 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_available_drains_burst_replies() {
|
||||||
|
let params = default_params();
|
||||||
|
let (mut client, server) = open_client_with_echo_server(¶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 super::*;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
probe::{CompletedSet, PendingMap, TimedOutMap},
|
|
||||||
session::ActiveSession,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||||
@@ -24,21 +18,10 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highest_received_seq_updates_across_wrap() {
|
fn highest_received_seq_updates_across_wrap() {
|
||||||
let mut session = ActiveSession {
|
let mut highest = Some(u32::MAX);
|
||||||
next_wire_seq: 0,
|
|
||||||
highest_received_seq: Some(u32::MAX),
|
|
||||||
packets_sent: 0,
|
|
||||||
start_mono: Instant::now(),
|
|
||||||
end_mono: None,
|
|
||||||
next_send_at: Instant::now(),
|
|
||||||
pending: PendingMap::new(8),
|
|
||||||
timed_out: TimedOutMap::new(8),
|
|
||||||
completed: CompletedSet::new(8),
|
|
||||||
sending_done: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
update_highest_received(&mut session, 0);
|
update_highest_received(&mut highest, 0);
|
||||||
assert_eq!(session.highest_received_seq, Some(0));
|
assert_eq!(highest, Some(0));
|
||||||
update_highest_received(&mut session, u32::MAX);
|
update_highest_received(&mut highest, u32::MAX);
|
||||||
assert_eq!(session.highest_received_seq, Some(0));
|
assert_eq!(highest, Some(0));
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
use std::{net::SocketAddr, time::Duration};
|
use std::{net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
|
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
||||||
|
/// bytes.
|
||||||
|
///
|
||||||
|
/// This is the maximum encoded server-fill string accepted by this client and
|
||||||
|
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
|
||||||
|
/// before opening a session.
|
||||||
|
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||||
|
|
||||||
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
||||||
|
/// Largest valid DSCP codepoint accepted by client configuration.
|
||||||
|
///
|
||||||
|
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
|
||||||
|
/// socket traffic-class field when configuring packet priority.
|
||||||
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
||||||
|
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
|
||||||
|
///
|
||||||
|
/// This is the public user-configuration bound for
|
||||||
|
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
|
||||||
|
/// TTL and hop-limit settings are configured as `1..=255`.
|
||||||
pub const MAX_TTL: u32 = 255;
|
pub const MAX_TTL: u32 = 255;
|
||||||
|
/// Largest UDP payload length accepted by client configuration, in bytes.
|
||||||
|
///
|
||||||
|
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
|
||||||
|
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
|
||||||
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
||||||
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
|
||||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
|
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
|
||||||
@@ -19,26 +38,110 @@ pub(crate) const MIN_OPEN_TIMEOUT: Duration = Duration::from_millis(200);
|
|||||||
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
||||||
|
|
||||||
|
/// Configuration for opening and running an IRTT client session.
|
||||||
|
///
|
||||||
|
/// This type describes both the protocol parameters sent in the IRTT open
|
||||||
|
/// request and the local client behavior used to drive the UDP socket. Values
|
||||||
|
/// that are negotiated by the server are available after opening the session
|
||||||
|
/// through [`NegotiatedParams`](crate::NegotiatedParams).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClientConfig {
|
pub struct ClientConfig {
|
||||||
|
/// Remote server name or address.
|
||||||
|
///
|
||||||
|
/// If no port is present, the default IRTT port 2112 is used. IPv6
|
||||||
|
/// literals may be supplied either bracketed or unbracketed when the
|
||||||
|
/// default port should be used.
|
||||||
pub server_addr: String,
|
pub server_addr: String,
|
||||||
|
/// Requested run duration.
|
||||||
|
///
|
||||||
|
/// `Some(duration)` requests a finite test and must be greater than zero.
|
||||||
|
/// `None` requests continuous mode and is encoded on the wire as a zero
|
||||||
|
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
|
||||||
|
/// without sending probes.
|
||||||
pub duration: Option<Duration>,
|
pub duration: Option<Duration>,
|
||||||
|
/// Requested spacing between probe sends.
|
||||||
|
///
|
||||||
|
/// The interval is encoded as nanoseconds in the open request and must be
|
||||||
|
/// greater than zero. The server may return a different interval depending
|
||||||
|
/// on the negotiated policy and server restrictions.
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
|
/// Requested echo packet payload length, in bytes.
|
||||||
|
///
|
||||||
|
/// The value must fit within the UDP payload limit after protocol overhead.
|
||||||
|
/// A server may reduce the requested length during negotiation.
|
||||||
pub length: u32,
|
pub length: u32,
|
||||||
|
/// Requested server receive-statistics fields in echo replies.
|
||||||
pub received_stats: ReceivedStats,
|
pub received_stats: ReceivedStats,
|
||||||
|
/// Requested timestamp placement in echo replies.
|
||||||
pub stamp_at: StampAt,
|
pub stamp_at: StampAt,
|
||||||
|
/// Requested server clock sources for timestamp fields.
|
||||||
pub clock: Clock,
|
pub clock: Clock,
|
||||||
|
/// Requested DSCP codepoint.
|
||||||
|
///
|
||||||
|
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
||||||
|
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
||||||
pub dscp: u8,
|
pub dscp: u8,
|
||||||
|
/// Optional HMAC key used to authenticate IRTT packets.
|
||||||
|
///
|
||||||
|
/// When present, open, echo, and close packets are encoded and decoded with
|
||||||
|
/// HMAC authentication. The peer must be configured with the same key.
|
||||||
pub hmac_key: Option<Vec<u8>>,
|
pub hmac_key: Option<Vec<u8>>,
|
||||||
|
/// Optional server payload fill request.
|
||||||
|
///
|
||||||
|
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
||||||
|
/// a non-empty server fill mode/value and must not exceed
|
||||||
|
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
|
||||||
pub server_fill: Option<String>,
|
pub server_fill: Option<String>,
|
||||||
|
/// Per-attempt receive timeouts used while opening the session.
|
||||||
|
///
|
||||||
|
/// The client sends an open request for each entry until a valid open reply
|
||||||
|
/// is received. The list must not be empty, and each timeout must be at
|
||||||
|
/// least 200 ms.
|
||||||
pub open_timeouts: Vec<Duration>,
|
pub open_timeouts: Vec<Duration>,
|
||||||
|
/// Whether opening the session should start a probe test or perform a
|
||||||
|
/// negotiation-only no-test exchange.
|
||||||
pub run_mode: RunMode,
|
pub run_mode: RunMode,
|
||||||
|
/// Policy for server changes to negotiable protocol parameters.
|
||||||
pub negotiation_policy: NegotiationPolicy,
|
pub negotiation_policy: NegotiationPolicy,
|
||||||
|
/// Local UDP socket configuration.
|
||||||
pub socket_config: SocketConfig,
|
pub socket_config: SocketConfig,
|
||||||
|
/// Time after sending a probe before the client reports it as lost.
|
||||||
|
///
|
||||||
|
/// This timeout is local client behavior; it is not negotiated with the
|
||||||
|
/// server. It must be greater than zero.
|
||||||
pub probe_timeout: Duration,
|
pub probe_timeout: Duration,
|
||||||
|
/// Maximum number of probes tracked as pending/timed-out/completed.
|
||||||
|
///
|
||||||
|
/// This bounds memory used for reply classification and must be greater
|
||||||
|
/// than zero. A very small value can reject sends when replies are still
|
||||||
|
/// outstanding.
|
||||||
pub max_pending_probes: usize,
|
pub max_pending_probes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authentication settings used by client sessions.
|
||||||
|
///
|
||||||
|
/// `ClientConfig` keeps its existing top-level HMAC field for source
|
||||||
|
/// compatibility. Managed multi-target callers can use this smaller type for
|
||||||
|
/// per-target auth overrides without carrying a full independent
|
||||||
|
/// [`ClientConfig`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ClientAuthConfig {
|
||||||
|
/// Optional HMAC key used to authenticate IRTT packets.
|
||||||
|
pub hmac_key: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<Vec<u8>>> for ClientAuthConfig {
|
||||||
|
fn from(hmac_key: Option<Vec<u8>>) -> Self {
|
||||||
|
Self { hmac_key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ClientAuthConfig> for Option<Vec<u8>> {
|
||||||
|
fn from(auth: ClientAuthConfig) -> Self {
|
||||||
|
auth.hmac_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ClientConfig {
|
impl Default for ClientConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -62,29 +165,65 @@ impl Default for ClientConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local UDP socket options used by [`ClientConfig`].
|
||||||
|
///
|
||||||
|
/// These settings affect how the client binds, resolves, and receives from the
|
||||||
|
/// socket. They do not change the IRTT protocol parameters negotiated with the
|
||||||
|
/// server.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct SocketConfig {
|
pub struct SocketConfig {
|
||||||
|
/// Local address to bind before connecting the UDP socket.
|
||||||
|
///
|
||||||
|
/// `None` binds to an unspecified address with an ephemeral port matching
|
||||||
|
/// the selected remote address family.
|
||||||
pub bind_addr: Option<SocketAddr>,
|
pub bind_addr: Option<SocketAddr>,
|
||||||
|
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
||||||
|
///
|
||||||
|
/// `None` leaves the platform default unchanged. Values must fit in the
|
||||||
|
/// platform socket option range; [`MAX_TTL`] is the public configuration
|
||||||
|
/// bound used by this crate.
|
||||||
pub ttl: Option<u32>,
|
pub ttl: Option<u32>,
|
||||||
|
/// Restrict name resolution to IPv4 addresses.
|
||||||
pub ipv4_only: bool,
|
pub ipv4_only: bool,
|
||||||
|
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
|
||||||
|
/// sockets.
|
||||||
pub ipv6_only: bool,
|
pub ipv6_only: bool,
|
||||||
|
/// Socket read timeout used after the session is open.
|
||||||
|
///
|
||||||
|
/// `None` leaves reads blocking for APIs that perform a single receive.
|
||||||
|
/// Managed sessions may replace `None` or long timeouts with a short
|
||||||
|
/// timeout so cooperative cancellation can be observed promptly.
|
||||||
pub recv_timeout: Option<Duration>,
|
pub recv_timeout: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How strictly to handle server-side negotiation restrictions.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum NegotiationPolicy {
|
pub enum NegotiationPolicy {
|
||||||
|
/// Reject any negotiated parameter that is more restrictive or different
|
||||||
|
/// than requested.
|
||||||
Strict,
|
Strict,
|
||||||
|
/// Accept documented server restrictions and report them in
|
||||||
|
/// [`NegotiatedParams`](crate::NegotiatedParams).
|
||||||
Loose,
|
Loose,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mode requested during the IRTT open exchange.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunMode {
|
pub enum RunMode {
|
||||||
|
/// Open a session and send echo probes for the negotiated run duration.
|
||||||
Normal,
|
Normal,
|
||||||
|
/// Complete negotiation without running the echo probe test.
|
||||||
NoTest,
|
NoTest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bound for a single receive-drain operation.
|
||||||
|
///
|
||||||
|
/// This is used by lower-level callers that drive [`Client`](crate::Client)
|
||||||
|
/// directly and want to cap how many datagrams are processed before returning
|
||||||
|
/// to their own event loop.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct RecvBudget {
|
pub struct RecvBudget {
|
||||||
|
/// Maximum number of datagrams to process before returning.
|
||||||
pub max_packets: usize,
|
pub max_packets: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,66 +2,176 @@ use std::{io, time::Duration};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Failure reported while configuring, opening, running, receiving, closing, or
|
||||||
|
/// joining an IRTT client session.
|
||||||
|
///
|
||||||
|
/// Configuration validation errors usually require changing
|
||||||
|
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
|
||||||
|
/// Network, protocol, and runtime errors may be transient, but can also reflect
|
||||||
|
/// peer incompatibility or an invalid session state.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum ClientError {
|
pub enum ClientError {
|
||||||
|
/// The configured server address could not be resolved to a socket address.
|
||||||
|
///
|
||||||
|
/// Check the host, port, address-family restrictions, and name service
|
||||||
|
/// configuration before retrying.
|
||||||
#[error("failed to resolve server address {addr:?}")]
|
#[error("failed to resolve server address {addr:?}")]
|
||||||
Resolve { addr: String },
|
Resolve { addr: String },
|
||||||
|
/// A UDP socket operation failed while binding, connecting, sending, or
|
||||||
|
/// receiving.
|
||||||
|
///
|
||||||
|
/// This can be a transient network or OS error, but may also reflect local
|
||||||
|
/// permissions, routing, or socket configuration.
|
||||||
#[error("socket error: {0}")]
|
#[error("socket error: {0}")]
|
||||||
Socket(#[from] io::Error),
|
Socket(#[from] io::Error),
|
||||||
|
/// Applying or reading a socket option failed.
|
||||||
|
///
|
||||||
|
/// This is usually caused by an unsupported option, an invalid local value,
|
||||||
|
/// or a platform-specific socket limitation. Changing socket configuration
|
||||||
|
/// is more likely to help than retrying unchanged.
|
||||||
#[error("socket option error while trying to {operation} for {remote}: {source}")]
|
#[error("socket option error while trying to {operation} for {remote}: {source}")]
|
||||||
SocketOption {
|
SocketOption {
|
||||||
operation: &'static str,
|
operation: &'static str,
|
||||||
remote: std::net::SocketAddr,
|
remote: std::net::SocketAddr,
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
|
/// Restoring the configured read timeout after open negotiation failed.
|
||||||
|
///
|
||||||
|
/// The session may have opened successfully, but the socket could not be
|
||||||
|
/// returned to the caller's requested receive-timeout behavior.
|
||||||
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
|
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
|
||||||
ReadTimeoutRestore { source: io::Error },
|
ReadTimeoutRestore { source: io::Error },
|
||||||
|
/// A packet failed IRTT protocol parsing or authentication.
|
||||||
|
///
|
||||||
|
/// Likely causes include malformed input, unsupported protocol data, or an
|
||||||
|
/// HMAC mismatch between client and server.
|
||||||
#[error("protocol error: {0}")]
|
#[error("protocol error: {0}")]
|
||||||
Protocol(#[from] irtt_proto::ProtoError),
|
Protocol(#[from] irtt_proto::ProtoError),
|
||||||
|
/// No valid open reply was received before all configured open attempts
|
||||||
|
/// timed out.
|
||||||
|
///
|
||||||
|
/// Retrying can succeed when the server or network is temporarily
|
||||||
|
/// unavailable. Persistent failures usually require changing the address,
|
||||||
|
/// authentication key, server configuration, or timeout settings.
|
||||||
#[error("all open requests timed out")]
|
#[error("all open requests timed out")]
|
||||||
OpenTimeout,
|
OpenTimeout,
|
||||||
|
/// An open-attempt timeout is below the client's supported minimum.
|
||||||
|
///
|
||||||
|
/// Increase the configured timeout before retrying.
|
||||||
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
|
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
|
||||||
OpenTimeoutTooSmall {
|
OpenTimeoutTooSmall {
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
minimum: Duration,
|
minimum: Duration,
|
||||||
},
|
},
|
||||||
|
/// The configured open-attempt timeout list is empty.
|
||||||
|
///
|
||||||
|
/// Provide at least one timeout before opening the session.
|
||||||
#[error("open_timeouts must not be empty")]
|
#[error("open_timeouts must not be empty")]
|
||||||
NoOpenTimeouts,
|
NoOpenTimeouts,
|
||||||
|
/// The server explicitly rejected the open request.
|
||||||
|
///
|
||||||
|
/// Retrying unchanged is unlikely to help unless the server configuration or
|
||||||
|
/// load has changed.
|
||||||
#[error("server rejected the open request")]
|
#[error("server rejected the open request")]
|
||||||
ServerRejected,
|
ServerRejected,
|
||||||
|
/// A no-test open request received a reply that did not have no-test close
|
||||||
|
/// semantics.
|
||||||
|
///
|
||||||
|
/// This indicates an incompatible or unexpected peer response.
|
||||||
#[error("unexpected no-test open reply")]
|
#[error("unexpected no-test open reply")]
|
||||||
UnexpectedNoTestReply,
|
UnexpectedNoTestReply,
|
||||||
|
/// A no-test open reply included a connection token.
|
||||||
|
///
|
||||||
|
/// No-test negotiation must not establish a probe session, so a non-zero
|
||||||
|
/// token indicates an incompatible or unexpected peer response.
|
||||||
#[error("no-test reply included a non-zero connection token: {token}")]
|
#[error("no-test reply included a non-zero connection token: {token}")]
|
||||||
NonZeroNoTestToken { token: u64 },
|
NonZeroNoTestToken { token: u64 },
|
||||||
|
/// The peer replied with a protocol version different from the requested
|
||||||
|
/// version.
|
||||||
|
///
|
||||||
|
/// This is a protocol compatibility error and requires compatible client
|
||||||
|
/// and server versions.
|
||||||
#[error("protocol version mismatch: requested {requested}, received {received}")]
|
#[error("protocol version mismatch: requested {requested}, received {received}")]
|
||||||
ProtocolVersionMismatch { requested: i64, received: i64 },
|
ProtocolVersionMismatch { requested: i64, received: i64 },
|
||||||
|
/// The server returned a zero connection token for a normal test session.
|
||||||
|
///
|
||||||
|
/// A normal session requires a non-zero token to correlate echo traffic, so
|
||||||
|
/// this indicates an invalid peer response.
|
||||||
#[error("server returned a zero connection token")]
|
#[error("server returned a zero connection token")]
|
||||||
ZeroToken,
|
ZeroToken,
|
||||||
|
/// Negotiated parameters were rejected by the configured negotiation policy.
|
||||||
|
///
|
||||||
|
/// Use a less restrictive configuration or
|
||||||
|
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
|
||||||
|
/// caller accepts documented server restrictions.
|
||||||
#[error("strict negotiation rejected changed params: {reason}")]
|
#[error("strict negotiation rejected changed params: {reason}")]
|
||||||
NegotiationRejected { reason: String },
|
NegotiationRejected { reason: String },
|
||||||
|
/// The requested operation requires an open session, but the client is only
|
||||||
|
/// connected.
|
||||||
#[error("client is not open")]
|
#[error("client is not open")]
|
||||||
NotOpen,
|
NotOpen,
|
||||||
|
/// The session is already open.
|
||||||
|
///
|
||||||
|
/// Continue running, receiving, or closing the existing session instead of
|
||||||
|
/// opening it again.
|
||||||
#[error("client session is already open")]
|
#[error("client session is already open")]
|
||||||
AlreadyOpen,
|
AlreadyOpen,
|
||||||
|
/// The no-test session has already completed.
|
||||||
|
///
|
||||||
|
/// Create a new client session to perform another negotiation.
|
||||||
#[error("client session is already completed")]
|
#[error("client session is already completed")]
|
||||||
AlreadyCompleted,
|
AlreadyCompleted,
|
||||||
|
/// The session is already closed.
|
||||||
|
///
|
||||||
|
/// Create a new client session before opening or running another test.
|
||||||
#[error("client session is already closed")]
|
#[error("client session is already closed")]
|
||||||
AlreadyClosed,
|
AlreadyClosed,
|
||||||
|
/// A duration could not be represented as nanoseconds for scheduling or
|
||||||
|
/// protocol encoding.
|
||||||
|
///
|
||||||
|
/// Use smaller configured or negotiated durations.
|
||||||
#[error("duration is too large to encode as nanoseconds")]
|
#[error("duration is too large to encode as nanoseconds")]
|
||||||
DurationOverflow,
|
DurationOverflow,
|
||||||
|
/// A client-maintained counter would overflow.
|
||||||
|
///
|
||||||
|
/// This is a runtime limit rather than a network failure. Start a new
|
||||||
|
/// session if the test duration or send volume reaches this limit.
|
||||||
#[error("counter {counter} overflowed")]
|
#[error("counter {counter} overflowed")]
|
||||||
CounterOverflow { counter: &'static str },
|
CounterOverflow { counter: &'static str },
|
||||||
|
/// Sending another probe would exceed the configured pending-probe limit.
|
||||||
|
///
|
||||||
|
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
|
||||||
|
/// reduce send rate, or receive replies and timeouts more often before
|
||||||
|
/// sending again.
|
||||||
#[error("pending probe limit exceeded ({limit})")]
|
#[error("pending probe limit exceeded ({limit})")]
|
||||||
PendingLimitExceeded { limit: usize },
|
PendingLimitExceeded { limit: usize },
|
||||||
|
/// Client configuration failed validation before the requested operation.
|
||||||
|
///
|
||||||
|
/// The reported reason names a value that must be changed before retrying.
|
||||||
#[error("invalid configuration: {reason}")]
|
#[error("invalid configuration: {reason}")]
|
||||||
InvalidConfig { reason: String },
|
InvalidConfig { reason: String },
|
||||||
|
/// A managed-session worker thread panicked before producing an outcome.
|
||||||
|
///
|
||||||
|
/// This is a runtime failure from joining the managed session, not a
|
||||||
|
/// protocol response from the server.
|
||||||
#[error("managed client worker thread panicked")]
|
#[error("managed client worker thread panicked")]
|
||||||
WorkerPanicked,
|
WorkerPanicked,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failure reported while setting up or receiving from an event subscription.
|
||||||
|
///
|
||||||
|
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
|
||||||
|
/// Once a subscription exists, this error type reports delivery failures observed
|
||||||
|
/// by [`EventSubscription`](crate::EventSubscription).
|
||||||
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum EventSubscriptionError {
|
pub enum EventSubscriptionError {
|
||||||
|
/// The subscription can no longer receive new events.
|
||||||
|
///
|
||||||
|
/// Already queued events are delivered first. After the queue is drained,
|
||||||
|
/// `recv` and `try_recv` return this error when the hub disconnects the
|
||||||
|
/// subscriber, the hub is shut down, or the subscription is disconnected by
|
||||||
|
/// its overflow policy.
|
||||||
#[error("event subscription is disconnected")]
|
#[error("event subscription is disconnected")]
|
||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
+229
-23
@@ -5,139 +5,360 @@ use std::{
|
|||||||
|
|
||||||
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
||||||
|
|
||||||
|
/// Result of the IRTT open exchange.
|
||||||
|
///
|
||||||
|
/// The lower-level [`Client`](crate::Client) API returns this before any echo
|
||||||
|
/// probes are driven. Managed sessions publish the contained lifecycle event to
|
||||||
|
/// subscribers immediately after a successful open.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OpenOutcome {
|
pub enum OpenOutcome {
|
||||||
|
/// A normal probe session was opened.
|
||||||
Started {
|
Started {
|
||||||
|
/// Resolved remote socket address used by the UDP socket.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Session token assigned by the server and used on echo/close packets.
|
||||||
token: u64,
|
token: u64,
|
||||||
|
/// Parameters returned by the server after negotiation.
|
||||||
negotiated: NegotiatedParams,
|
negotiated: NegotiatedParams,
|
||||||
|
/// Lifecycle event corresponding to this outcome.
|
||||||
event: ClientEvent,
|
event: ClientEvent,
|
||||||
},
|
},
|
||||||
|
/// A no-test open exchange completed without starting a probe session.
|
||||||
NoTestCompleted {
|
NoTestCompleted {
|
||||||
|
/// Resolved remote socket address used by the UDP socket.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Parameters returned by the server after negotiation.
|
||||||
negotiated: NegotiatedParams,
|
negotiated: NegotiatedParams,
|
||||||
|
/// Lifecycle event corresponding to this outcome.
|
||||||
event: ClientEvent,
|
event: ClientEvent,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event emitted by an IRTT client session.
|
||||||
|
///
|
||||||
|
/// Events form the public lifecycle and measurement stream for callers. UIs
|
||||||
|
/// usually display lifecycle, warning, and per-packet events directly; summary
|
||||||
|
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
|
||||||
|
/// `LateReply`, and `DuplicateReply` events or pass the stream to
|
||||||
|
/// `irtt-stats`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ClientEvent {
|
pub enum ClientEvent {
|
||||||
|
/// A normal session has opened and is ready to send echo probes.
|
||||||
SessionStarted {
|
SessionStarted {
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Session token assigned by the server.
|
||||||
token: u64,
|
token: u64,
|
||||||
|
/// Negotiated protocol parameters for this session.
|
||||||
negotiated: NegotiatedParams,
|
negotiated: NegotiatedParams,
|
||||||
|
/// Client timestamp when the open reply was accepted.
|
||||||
at: ClientTimestamp,
|
at: ClientTimestamp,
|
||||||
},
|
},
|
||||||
|
/// A negotiation-only no-test exchange completed.
|
||||||
|
///
|
||||||
|
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
|
||||||
|
/// expected for this open outcome.
|
||||||
NoTestCompleted {
|
NoTestCompleted {
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Negotiated protocol parameters returned by the server.
|
||||||
negotiated: NegotiatedParams,
|
negotiated: NegotiatedParams,
|
||||||
|
/// Client timestamp when the open reply was accepted.
|
||||||
at: ClientTimestamp,
|
at: ClientTimestamp,
|
||||||
},
|
},
|
||||||
|
/// The client sent a close request and considers the session closed.
|
||||||
|
///
|
||||||
|
/// This is a local lifecycle event, not an acknowledgement from the server.
|
||||||
|
/// Managed sessions disconnect event subscribers after the worker exits,
|
||||||
|
/// while leaving already queued events available to drain.
|
||||||
SessionClosed {
|
SessionClosed {
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Session token that was closed.
|
||||||
token: u64,
|
token: u64,
|
||||||
|
/// Client timestamp when the close request was sent.
|
||||||
at: ClientTimestamp,
|
at: ClientTimestamp,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// An echo request was sent.
|
||||||
|
///
|
||||||
|
/// This records local scheduling and send-call timing. It does not imply a
|
||||||
|
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
|
||||||
|
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
|
||||||
|
/// packet lifecycle state.
|
||||||
EchoSent {
|
EchoSent {
|
||||||
|
/// Wire sequence number sent in the echo request.
|
||||||
seq: u32,
|
seq: u32,
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Monotonic deadline at which the probe was scheduled to be sent.
|
||||||
scheduled_at: Instant,
|
scheduled_at: Instant,
|
||||||
|
/// Client wall/monotonic timestamp captured for the send.
|
||||||
sent_at: ClientTimestamp,
|
sent_at: ClientTimestamp,
|
||||||
|
/// Number of bytes passed to the UDP socket.
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
/// Elapsed time spent in the socket send call.
|
||||||
send_call: Duration,
|
send_call: Duration,
|
||||||
|
/// Absolute difference between the scheduled send time and actual send
|
||||||
|
/// timestamp.
|
||||||
timer_error: Duration,
|
timer_error: Duration,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// First in-window reply for a pending echo request.
|
||||||
|
///
|
||||||
|
/// This is the primary successful measurement event. RTT and optional
|
||||||
|
/// server timing/statistics fields should generally be aggregated from this
|
||||||
|
/// variant.
|
||||||
EchoReply {
|
EchoReply {
|
||||||
|
/// Wire sequence number from the echo reply.
|
||||||
seq: u32,
|
seq: u32,
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Client timestamp recorded when the matching request was sent.
|
||||||
sent_at: ClientTimestamp,
|
sent_at: ClientTimestamp,
|
||||||
|
/// Client timestamp recorded when the reply was received.
|
||||||
received_at: ClientTimestamp,
|
received_at: ClientTimestamp,
|
||||||
|
/// Round-trip timing sample for this reply.
|
||||||
rtt: RttSample,
|
rtt: RttSample,
|
||||||
|
/// Optional server-reported timestamps and processing time.
|
||||||
server_timing: Option<ServerTiming>,
|
server_timing: Option<ServerTiming>,
|
||||||
|
/// Optional one-way delay values derived from client/server wall clocks.
|
||||||
one_way: Option<OneWayDelaySample>,
|
one_way: Option<OneWayDelaySample>,
|
||||||
|
/// Optional server-reported received-statistics sample.
|
||||||
received_stats: Option<ReceivedStatsSample>,
|
received_stats: Option<ReceivedStatsSample>,
|
||||||
|
/// UDP datagram size received from the socket.
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||||
packet_meta: PacketMeta,
|
packet_meta: PacketMeta,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A sent echo request exceeded the local probe timeout before a reply was
|
||||||
|
/// matched.
|
||||||
|
///
|
||||||
|
/// A later datagram for the same sequence may still arrive and will be
|
||||||
|
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
|
||||||
EchoLoss {
|
EchoLoss {
|
||||||
|
/// Wire sequence number that timed out.
|
||||||
seq: u32,
|
seq: u32,
|
||||||
|
/// Client timestamp recorded when the request was sent.
|
||||||
sent_at: ClientTimestamp,
|
sent_at: ClientTimestamp,
|
||||||
|
/// Monotonic deadline at which the probe was declared lost.
|
||||||
timeout_at: std::time::Instant,
|
timeout_at: std::time::Instant,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Additional reply for a sequence that was already completed.
|
||||||
|
///
|
||||||
|
/// Duplicates are not primary RTT samples. They are useful for diagnostics
|
||||||
|
/// and packet counters, but should not be aggregated as successful first
|
||||||
|
/// replies.
|
||||||
DuplicateReply {
|
DuplicateReply {
|
||||||
|
/// Wire sequence number from the duplicate reply.
|
||||||
seq: u32,
|
seq: u32,
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Client timestamp recorded when the duplicate was received.
|
||||||
received_at: ClientTimestamp,
|
received_at: ClientTimestamp,
|
||||||
|
/// UDP datagram size received from the socket.
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Reply for a sequence that was no longer pending.
|
||||||
|
///
|
||||||
|
/// This commonly means the probe had already been reported as lost, or the
|
||||||
|
/// reply arrived out of the client's current tracking window. When the
|
||||||
|
/// original send metadata is still retained, RTT and one-way timing are
|
||||||
|
/// included; otherwise those fields are `None`.
|
||||||
LateReply {
|
LateReply {
|
||||||
|
/// Wire sequence number from the late reply.
|
||||||
seq: u32,
|
seq: u32,
|
||||||
|
/// Highest sequence number accepted as an in-window first reply so far.
|
||||||
highest_seen: u32,
|
highest_seen: u32,
|
||||||
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
|
/// Original client send timestamp, when still retained.
|
||||||
sent_at: Option<ClientTimestamp>,
|
sent_at: Option<ClientTimestamp>,
|
||||||
|
/// Client timestamp recorded when the late reply was received.
|
||||||
received_at: ClientTimestamp,
|
received_at: ClientTimestamp,
|
||||||
|
/// RTT sample computed when the original send timestamp is available.
|
||||||
rtt: Option<RttSample>,
|
rtt: Option<RttSample>,
|
||||||
|
/// Optional server-reported timestamps and processing time.
|
||||||
server_timing: Option<ServerTiming>,
|
server_timing: Option<ServerTiming>,
|
||||||
|
/// Optional one-way delay values derived from client/server wall clocks.
|
||||||
one_way: Option<OneWayDelaySample>,
|
one_way: Option<OneWayDelaySample>,
|
||||||
|
/// Optional server-reported received-statistics sample.
|
||||||
received_stats: Option<ReceivedStatsSample>,
|
received_stats: Option<ReceivedStatsSample>,
|
||||||
|
/// UDP datagram size received from the socket.
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||||
packet_meta: PacketMeta,
|
packet_meta: PacketMeta,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Non-fatal condition observed while receiving or classifying packets.
|
||||||
|
///
|
||||||
|
/// Warning events are diagnostic. They do not close the session by
|
||||||
|
/// themselves and are not measurement samples.
|
||||||
Warning {
|
Warning {
|
||||||
|
/// Machine-readable warning category.
|
||||||
kind: WarningKind,
|
kind: WarningKind,
|
||||||
|
/// Human-readable diagnostic text.
|
||||||
message: String,
|
message: String,
|
||||||
|
/// Client timestamp when the warning was emitted.
|
||||||
at: ClientTimestamp,
|
at: ClientTimestamp,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Category for a non-fatal [`ClientEvent::Warning`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum WarningKind {
|
pub enum WarningKind {
|
||||||
|
/// A received datagram could not be decoded as a relevant IRTT packet.
|
||||||
MalformedOrUnrelatedPacket,
|
MalformedOrUnrelatedPacket,
|
||||||
|
/// A decoded packet used a token that does not match the open session.
|
||||||
WrongToken,
|
WrongToken,
|
||||||
|
/// A decoded reply could not be matched to tracked probe state.
|
||||||
UntrackedReply,
|
UntrackedReply,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Round-trip timing for one echo reply.
|
||||||
|
///
|
||||||
|
/// `raw` is the client-observed elapsed time from sending the echo request to
|
||||||
|
/// receiving the reply and is therefore non-negative. When the server reports
|
||||||
|
/// enough monotonic timing to compute processing time, `adjusted` subtracts
|
||||||
|
/// that server processing from `raw`. `effective` is the value used by reporting
|
||||||
|
/// and statistics: adjusted when available, otherwise raw converted to a signed
|
||||||
|
/// value.
|
||||||
|
///
|
||||||
|
/// Adjusted and effective RTT values are signed. They may be negative if the
|
||||||
|
/// reported server processing time is greater than the raw client-observed RTT.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct RttSample {
|
pub struct RttSample {
|
||||||
|
/// Client-observed RTT from send to receive.
|
||||||
pub raw: Duration,
|
pub raw: Duration,
|
||||||
pub adjusted: Option<Duration>,
|
/// Signed RTT adjusted for server processing when server processing is available.
|
||||||
pub effective: Duration,
|
pub adjusted: Option<SignedDuration>,
|
||||||
pub adjusted_signed: Option<SignedDuration>,
|
/// Signed effective RTT used by stats and CLI reporting.
|
||||||
pub effective_signed: SignedDuration,
|
///
|
||||||
|
/// This is `adjusted` when server processing is available, otherwise `raw`
|
||||||
|
/// converted to a signed duration.
|
||||||
|
pub effective: SignedDuration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Signed nanosecond-backed duration used for derived timing values.
|
||||||
|
///
|
||||||
|
/// This type preserves negative values that can arise after subtracting server
|
||||||
|
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||||
|
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||||
|
/// which is non-negative and represented by [`Duration`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct SignedDuration {
|
pub struct SignedDuration {
|
||||||
pub ns: i128,
|
ns: i128,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SignedDuration {
|
||||||
|
/// Construct a signed duration from nanoseconds.
|
||||||
|
pub const fn from_nanos(ns: i128) -> Self {
|
||||||
|
Self { ns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a non-negative [`Duration`] into a signed duration.
|
||||||
|
///
|
||||||
|
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
|
||||||
|
pub fn from_duration(duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the duration in nanoseconds.
|
||||||
|
pub const fn as_nanos(self) -> i128 {
|
||||||
|
self.ns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the duration in whole microseconds, truncated toward zero.
|
||||||
|
pub const fn as_micros(self) -> i128 {
|
||||||
|
self.ns / 1_000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the duration in whole milliseconds, truncated toward zero.
|
||||||
|
pub const fn as_millis(self) -> i128 {
|
||||||
|
self.ns / 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this duration is less than zero.
|
||||||
|
pub const fn is_negative(self) -> bool {
|
||||||
|
self.ns < 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Duration> for SignedDuration {
|
||||||
|
fn from(value: Duration) -> Self {
|
||||||
|
Self::from_duration(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-reported timing fields decoded from an echo reply.
|
||||||
|
///
|
||||||
|
/// These values are optional because the negotiated timestamp mode, clock mode,
|
||||||
|
/// server implementation, or packet contents may omit individual fields.
|
||||||
|
/// Timestamp values are nanoseconds in the server's reported wall-clock or
|
||||||
|
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
|
||||||
|
/// reported a zero timestamp.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ServerTiming {
|
pub struct ServerTiming {
|
||||||
|
/// Server wall-clock receive timestamp, in nanoseconds.
|
||||||
pub receive_wall_ns: Option<i64>,
|
pub receive_wall_ns: Option<i64>,
|
||||||
|
/// Server monotonic receive timestamp, in nanoseconds.
|
||||||
pub receive_mono_ns: Option<i64>,
|
pub receive_mono_ns: Option<i64>,
|
||||||
|
/// Server wall-clock send timestamp, in nanoseconds.
|
||||||
pub send_wall_ns: Option<i64>,
|
pub send_wall_ns: Option<i64>,
|
||||||
|
/// Server monotonic send timestamp, in nanoseconds.
|
||||||
pub send_mono_ns: Option<i64>,
|
pub send_mono_ns: Option<i64>,
|
||||||
|
/// Server wall-clock midpoint timestamp, in nanoseconds.
|
||||||
pub midpoint_wall_ns: Option<i64>,
|
pub midpoint_wall_ns: Option<i64>,
|
||||||
|
/// Server monotonic midpoint timestamp, in nanoseconds.
|
||||||
pub midpoint_mono_ns: Option<i64>,
|
pub midpoint_mono_ns: Option<i64>,
|
||||||
|
/// Server processing time computed from monotonic receive/send timestamps.
|
||||||
|
///
|
||||||
|
/// `None` means the required monotonic timestamps were unavailable or
|
||||||
|
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
|
||||||
|
/// was available and zero.
|
||||||
pub processing: Option<Duration>,
|
pub processing: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directional one-way delay samples computed from wall-clock timestamps.
|
||||||
|
///
|
||||||
|
/// Each directional field is an `Option<SignedDuration>`. `None` means the
|
||||||
|
/// required client or server wall-clock timestamp was unavailable, or the
|
||||||
|
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
|
||||||
|
/// computed from available timestamps.
|
||||||
|
///
|
||||||
|
/// Negative computed values are preserved as `Some` instead of being clamped to
|
||||||
|
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
|
||||||
|
/// between the client and server.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct OneWayDelaySample {
|
pub struct OneWayDelaySample {
|
||||||
pub client_to_server: Option<Duration>,
|
/// Client-to-server delay.
|
||||||
pub server_to_client: Option<Duration>,
|
///
|
||||||
|
/// This is computed from the client send wall time and the server receive
|
||||||
|
/// wall time when both are available.
|
||||||
|
pub client_to_server: Option<SignedDuration>,
|
||||||
|
/// Server-to-client delay.
|
||||||
|
///
|
||||||
|
/// This is computed from the server send wall time and the client receive
|
||||||
|
/// wall time when both are available.
|
||||||
|
pub server_to_client: Option<SignedDuration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server-reported receive counters decoded from an echo reply.
|
||||||
|
///
|
||||||
|
/// These fields are optional because the negotiated received-statistics mode or
|
||||||
|
/// server implementation may omit them. `None` means unavailable; `Some(0)`
|
||||||
|
/// means the server reported the value and it was zero.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ReceivedStatsSample {
|
pub struct ReceivedStatsSample {
|
||||||
|
/// Server-reported count of received packets, when available.
|
||||||
pub count: Option<u32>,
|
pub count: Option<u32>,
|
||||||
|
/// Server-reported receive window value, when available.
|
||||||
pub window: Option<u64>,
|
pub window: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,18 +373,3 @@ pub struct PacketMeta {
|
|||||||
pub ecn: Option<u8>,
|
pub ecn: Option<u8>,
|
||||||
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
|
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::PacketMeta;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packet_meta_default_is_unavailable() {
|
|
||||||
let meta = PacketMeta::default();
|
|
||||||
|
|
||||||
assert_eq!(meta.traffic_class, None);
|
|
||||||
assert_eq!(meta.dscp, None);
|
|
||||||
assert_eq!(meta.ecn, None);
|
|
||||||
assert_eq!(meta.kernel_rx_timestamp, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,50 @@
|
|||||||
|
//! Reusable client/session/event layer for IRTT-compatible probes.
|
||||||
|
//!
|
||||||
|
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
|
||||||
|
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
|
||||||
|
//! values for callers to consume directly or aggregate with `irtt-stats`.
|
||||||
|
//!
|
||||||
|
//! Timing values intentionally preserve signed measurement semantics. When
|
||||||
|
//! server timing is available, [`RttSample::effective`] is adjusted for server
|
||||||
|
//! processing and can be negative if the reported server processing time exceeds
|
||||||
|
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
|
||||||
|
//! also signed when the required wall-clock timestamps are available; negative
|
||||||
|
//! values usually indicate client/server clock skew.
|
||||||
|
//!
|
||||||
|
//! A managed session can drive the socket loop on a worker thread and publish
|
||||||
|
//! events through a subscription:
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use std::time::Duration;
|
||||||
|
//!
|
||||||
|
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig};
|
||||||
|
//!
|
||||||
|
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let config = ClientConfig {
|
||||||
|
//! server_addr: "127.0.0.1:2112".to_owned(),
|
||||||
|
//! duration: Some(Duration::from_secs(10)),
|
||||||
|
//! interval: Duration::from_secs(1),
|
||||||
|
//! ..ClientConfig::default()
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! let (session, events) =
|
||||||
|
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
|
||||||
|
//!
|
||||||
|
//! while let Ok(event) = events.recv() {
|
||||||
|
//! match event {
|
||||||
|
//! ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||||
|
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
|
||||||
|
//! }
|
||||||
|
//! ClientEvent::SessionClosed { .. } => break,
|
||||||
|
//! _ => {}
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! let _outcome = session.join()?;
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
#![cfg_attr(
|
#![cfg_attr(
|
||||||
not(all(target_os = "linux", feature = "ancillary")),
|
not(all(target_os = "linux", feature = "ancillary")),
|
||||||
forbid(unsafe_code)
|
forbid(unsafe_code)
|
||||||
@@ -12,6 +59,7 @@ mod managed;
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod probe;
|
mod probe;
|
||||||
mod receive;
|
mod receive;
|
||||||
|
mod runtime;
|
||||||
mod session;
|
mod session;
|
||||||
mod socket;
|
mod socket;
|
||||||
mod socket_options;
|
mod socket_options;
|
||||||
@@ -19,8 +67,8 @@ mod timing;
|
|||||||
|
|
||||||
pub use client::Client;
|
pub use client::Client;
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT,
|
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
|
||||||
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
||||||
};
|
};
|
||||||
pub use error::{ClientError, EventSubscriptionError};
|
pub use error::{ClientError, EventSubscriptionError};
|
||||||
pub use event::{
|
pub use event::{
|
||||||
@@ -28,8 +76,11 @@ pub use event::{
|
|||||||
ServerTiming, SignedDuration, WarningKind,
|
ServerTiming, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
pub use managed::{
|
pub use managed::{
|
||||||
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession,
|
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientGroup,
|
||||||
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow,
|
ManagedClientGroupConfig, ManagedClientGroupSession, ManagedClientSession,
|
||||||
|
ManagedGroupEndReason, ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig,
|
||||||
|
ManagedTargetEndReason, ManagedTargetOutcome, SessionEndReason, SessionOutcome,
|
||||||
|
SubscriberConfig, SubscriberOverflow, TargetEvent, TargetEventSubscription, TargetId,
|
||||||
};
|
};
|
||||||
pub use session::{NegotiatedParams, NegotiationRestriction};
|
pub use session::{NegotiatedParams, NegotiationRestriction};
|
||||||
pub use timing::ClientTimestamp;
|
pub use timing::ClientTimestamp;
|
||||||
@@ -3,20 +3,31 @@ use std::sync::{
|
|||||||
Arc,
|
Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Cloneable cooperative cancellation flag.
|
||||||
|
///
|
||||||
|
/// Managed sessions use this internally to ask their worker loop to stop. It is
|
||||||
|
/// exported for callers that want the same simple cancellation primitive in
|
||||||
|
/// code built around `irtt-client`.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CancellationToken {
|
pub struct CancellationToken {
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CancellationToken {
|
impl CancellationToken {
|
||||||
|
/// Create a token in the non-cancelled state.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark the token as cancelled.
|
||||||
|
///
|
||||||
|
/// Cancellation is idempotent and is visible through all clones of the
|
||||||
|
/// token.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
self.cancelled.store(true, Ordering::SeqCst);
|
self.cancelled.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return whether cancellation has been requested.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_cancelled(&self) -> bool {
|
pub fn is_cancelled(&self) -> bool {
|
||||||
self.cancelled.load(Ordering::SeqCst)
|
self.cancelled.load(Ordering::SeqCst)
|
||||||
@@ -27,11 +38,6 @@ impl CancellationToken {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_starts_uncancelled() {
|
|
||||||
assert!(!CancellationToken::new().is_cancelled());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancel_is_idempotent_and_visible_through_clones() {
|
fn cancel_is_idempotent_and_visible_through_clones() {
|
||||||
let token = CancellationToken::new();
|
let token = CancellationToken::new();
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -8,9 +8,17 @@ use std::{
|
|||||||
|
|
||||||
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
||||||
|
|
||||||
|
/// Configuration for one managed event subscriber.
|
||||||
|
///
|
||||||
|
/// Each subscriber has an independent bounded queue. The queue bounds memory
|
||||||
|
/// growth when event production is faster than that subscriber's consumer.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct SubscriberConfig {
|
pub struct SubscriberConfig {
|
||||||
|
/// Maximum number of events buffered for this subscriber.
|
||||||
|
///
|
||||||
|
/// Must be greater than zero.
|
||||||
pub capacity: usize,
|
pub capacity: usize,
|
||||||
|
/// Policy applied when publishing to a full subscriber queue.
|
||||||
pub overflow: SubscriberOverflow,
|
pub overflow: SubscriberOverflow,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,15 +45,20 @@ pub enum SubscriberOverflow {
|
|||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
/// Publish/subscribe fan-out for managed event values.
|
||||||
pub struct EventHub {
|
///
|
||||||
inner: Arc<HubInner>,
|
/// `EventHub` is used by managed sessions internally and is also exported for
|
||||||
|
/// callers that want the same bounded subscription behavior around their own
|
||||||
|
/// event producer. Publishing clones each event once per subscriber.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EventHub<T = ClientEvent> {
|
||||||
|
inner: Arc<HubInner<T>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct HubInner {
|
struct HubInner<T> {
|
||||||
next_id: AtomicU64,
|
next_id: AtomicU64,
|
||||||
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner>>>,
|
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner<T>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle for receiving managed client events.
|
/// Handle for receiving managed client events.
|
||||||
@@ -55,26 +68,35 @@ struct HubInner {
|
|||||||
/// when that queue is full. Dropping the handle unregisters the subscriber.
|
/// when that queue is full. Dropping the handle unregisters the subscriber.
|
||||||
#[must_use = "dropping the subscription unregisters it"]
|
#[must_use = "dropping the subscription unregisters it"]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EventSubscription {
|
pub struct EventSubscription<T = ClientEvent> {
|
||||||
id: u64,
|
id: u64,
|
||||||
hub: Weak<HubInner>,
|
hub: Weak<HubInner<T>>,
|
||||||
inner: Arc<SubscriberInner>,
|
inner: Arc<SubscriberInner<T>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SubscriberInner {
|
struct SubscriberInner<T> {
|
||||||
state: Mutex<SubscriberState>,
|
state: Mutex<SubscriberState<T>>,
|
||||||
available: Condvar,
|
available: Condvar,
|
||||||
config: SubscriberConfig,
|
config: SubscriberConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SubscriberState {
|
struct SubscriberState<T> {
|
||||||
queue: VecDeque<ClientEvent>,
|
queue: VecDeque<T>,
|
||||||
connected: bool,
|
connected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHub {
|
impl<T> Clone for EventHub<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: self.inner.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> EventHub<T> {
|
||||||
|
/// Create an empty event hub with no subscribers.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(HubInner {
|
inner: Arc::new(HubInner {
|
||||||
@@ -84,10 +106,13 @@ impl EventHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a new subscriber with its own bounded queue.
|
||||||
|
///
|
||||||
|
/// Returns an error when `config.capacity` is zero.
|
||||||
pub fn subscribe(
|
pub fn subscribe(
|
||||||
&self,
|
&self,
|
||||||
config: SubscriberConfig,
|
config: SubscriberConfig,
|
||||||
) -> Result<EventSubscription, crate::ClientError> {
|
) -> Result<EventSubscription<T>, crate::ClientError> {
|
||||||
if config.capacity == 0 {
|
if config.capacity == 0 {
|
||||||
return Err(crate::ClientError::InvalidConfig {
|
return Err(crate::ClientError::InvalidConfig {
|
||||||
reason: "subscriber capacity must be greater than zero".to_owned(),
|
reason: "subscriber capacity must be greater than zero".to_owned(),
|
||||||
@@ -116,8 +141,41 @@ impl EventHub {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn publish(&self, event: ClientEvent) {
|
/// Disconnect all subscribers after leaving their already queued events
|
||||||
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
|
/// available to drain.
|
||||||
|
pub fn disconnect_all(&self) {
|
||||||
|
let subscribers: Vec<Arc<SubscriberInner<T>>> = self
|
||||||
|
.inner
|
||||||
|
.subscribers
|
||||||
|
.lock()
|
||||||
|
.expect("event hub mutex poisoned")
|
||||||
|
.drain()
|
||||||
|
.map(|(_, subscriber)| subscriber)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for subscriber in subscribers {
|
||||||
|
subscriber.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn subscriber_count(&self) -> usize {
|
||||||
|
self.inner
|
||||||
|
.subscribers
|
||||||
|
.lock()
|
||||||
|
.expect("event hub mutex poisoned")
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone> EventHub<T> {
|
||||||
|
/// Publish an event to all currently connected subscribers.
|
||||||
|
///
|
||||||
|
/// Slow subscribers are handled according to their
|
||||||
|
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
|
||||||
|
/// subscriber to consume queued events.
|
||||||
|
pub fn publish(&self, event: T) {
|
||||||
|
let subscribers: Vec<(u64, Arc<SubscriberInner<T>>)> = self
|
||||||
.inner
|
.inner
|
||||||
.subscribers
|
.subscribers
|
||||||
.lock()
|
.lock()
|
||||||
@@ -148,45 +206,21 @@ impl EventHub {
|
|||||||
subscribers.remove(&id);
|
subscribers.remove(&id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disconnect_all(&self) {
|
|
||||||
let subscribers: Vec<Arc<SubscriberInner>> = self
|
|
||||||
.inner
|
|
||||||
.subscribers
|
|
||||||
.lock()
|
|
||||||
.expect("event hub mutex poisoned")
|
|
||||||
.drain()
|
|
||||||
.map(|(_, subscriber)| subscriber)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for subscriber in subscribers {
|
|
||||||
subscriber.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn subscriber_count(&self) -> usize {
|
|
||||||
self.inner
|
|
||||||
.subscribers
|
|
||||||
.lock()
|
|
||||||
.expect("event hub mutex poisoned")
|
|
||||||
.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EventHub {
|
impl<T> Default for EventHub<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventSubscription {
|
impl<T> EventSubscription<T> {
|
||||||
/// Block until the next queued event is available.
|
/// Block until the next queued event is available.
|
||||||
///
|
///
|
||||||
/// If the subscription is disconnected, this returns
|
/// If the subscription is disconnected, this returns
|
||||||
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
||||||
/// have been drained.
|
/// have been drained.
|
||||||
pub fn recv(&self) -> Result<ClientEvent, EventSubscriptionError> {
|
pub fn recv(&self) -> Result<T, EventSubscriptionError> {
|
||||||
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
||||||
loop {
|
loop {
|
||||||
if let Some(event) = state.queue.pop_front() {
|
if let Some(event) = state.queue.pop_front() {
|
||||||
@@ -209,7 +243,7 @@ impl EventSubscription {
|
|||||||
/// is queued. If the subscription is disconnected, this returns
|
/// is queued. If the subscription is disconnected, this returns
|
||||||
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
||||||
/// have been drained.
|
/// have been drained.
|
||||||
pub fn try_recv(&self) -> Result<Option<ClientEvent>, EventSubscriptionError> {
|
pub fn try_recv(&self) -> Result<Option<T>, EventSubscriptionError> {
|
||||||
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
||||||
if let Some(event) = state.queue.pop_front() {
|
if let Some(event) = state.queue.pop_front() {
|
||||||
return Ok(Some(event));
|
return Ok(Some(event));
|
||||||
@@ -221,7 +255,7 @@ impl EventSubscription {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for EventSubscription {
|
impl<T> Drop for EventSubscription<T> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.inner.disconnect();
|
self.inner.disconnect();
|
||||||
if let Some(hub) = self.hub.upgrade() {
|
if let Some(hub) = self.hub.upgrade() {
|
||||||
@@ -233,8 +267,8 @@ impl Drop for EventSubscription {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubscriberInner {
|
impl<T> SubscriberInner<T> {
|
||||||
fn publish(&self, event: ClientEvent) -> bool {
|
fn publish(&self, event: T) -> bool {
|
||||||
let mut state = self.state.lock().expect("subscriber mutex poisoned");
|
let mut state = self.state.lock().expect("subscriber mutex poisoned");
|
||||||
if !state.connected {
|
if !state.connected {
|
||||||
return false;
|
return false;
|
||||||
@@ -290,14 +324,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn assert_next(
|
||||||
fn publishes_to_one_subscriber() {
|
sub: &EventSubscription,
|
||||||
let hub = EventHub::new();
|
expected: Result<Option<&str>, EventSubscriptionError>,
|
||||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
) {
|
||||||
|
match (sub.try_recv(), expected) {
|
||||||
hub.publish(event(1));
|
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
|
||||||
|
(Ok(None), Ok(None)) => {}
|
||||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
|
||||||
|
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -313,56 +349,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_newest_keeps_existing_events() {
|
fn full_subscriber_overflow_policy_is_applied() {
|
||||||
let hub = EventHub::new();
|
let cases = [
|
||||||
let sub = hub
|
(
|
||||||
.subscribe(SubscriberConfig {
|
SubscriberOverflow::DropNewest,
|
||||||
capacity: 1,
|
Ok(Some("event-1")),
|
||||||
overflow: SubscriberOverflow::DropNewest,
|
Ok(None),
|
||||||
})
|
),
|
||||||
.unwrap();
|
(
|
||||||
|
SubscriberOverflow::DropOldest,
|
||||||
|
Ok(Some("event-2")),
|
||||||
|
Ok(None),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SubscriberOverflow::Disconnect,
|
||||||
|
Err(EventSubscriptionError::Disconnected),
|
||||||
|
Err(EventSubscriptionError::Disconnected),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
hub.publish(event(1));
|
for (overflow, first_expected, second_expected) in cases {
|
||||||
hub.publish(event(2));
|
let hub = EventHub::new();
|
||||||
|
let sub = hub
|
||||||
|
.subscribe(SubscriberConfig {
|
||||||
|
capacity: 1,
|
||||||
|
overflow,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
hub.publish(event(1));
|
||||||
assert!(sub.try_recv().unwrap().is_none());
|
hub.publish(event(2));
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
assert_next(&sub, first_expected);
|
||||||
fn drop_oldest_replaces_existing_events() {
|
assert_next(&sub, second_expected);
|
||||||
let hub = EventHub::new();
|
}
|
||||||
let sub = hub
|
|
||||||
.subscribe(SubscriberConfig {
|
|
||||||
capacity: 1,
|
|
||||||
overflow: SubscriberOverflow::DropOldest,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
hub.publish(event(1));
|
|
||||||
hub.publish(event(2));
|
|
||||||
|
|
||||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
|
|
||||||
assert!(sub.try_recv().unwrap().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnect_removes_full_subscriber() {
|
|
||||||
let hub = EventHub::new();
|
|
||||||
let sub = hub
|
|
||||||
.subscribe(SubscriberConfig {
|
|
||||||
capacity: 1,
|
|
||||||
overflow: SubscriberOverflow::Disconnect,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
hub.publish(event(1));
|
|
||||||
hub.publish(event(2));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
sub.try_recv().unwrap_err(),
|
|
||||||
EventSubscriptionError::Disconnected
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -401,7 +421,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dropping_subscription_unregisters_from_hub() {
|
fn dropping_subscription_unregisters_from_hub() {
|
||||||
let hub = EventHub::new();
|
let hub: EventHub = EventHub::new();
|
||||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||||
assert_eq!(hub.subscriber_count(), 1);
|
assert_eq!(hub.subscriber_count(), 1);
|
||||||
|
|
||||||
@@ -412,7 +432,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disconnect_all_wakes_blocking_receivers() {
|
fn disconnect_all_wakes_blocking_receivers() {
|
||||||
let hub = EventHub::new();
|
let hub: EventHub = EventHub::new();
|
||||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||||
hub.disconnect_all();
|
hub.disconnect_all();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
mod cancellation;
|
mod cancellation;
|
||||||
|
mod group;
|
||||||
mod hub;
|
mod hub;
|
||||||
mod runner;
|
mod runner;
|
||||||
|
|
||||||
pub use cancellation::CancellationToken;
|
pub use cancellation::CancellationToken;
|
||||||
|
pub use group::{
|
||||||
|
ManagedClientGroup, ManagedClientGroupConfig, ManagedClientGroupSession, ManagedGroupEndReason,
|
||||||
|
ManagedGroupOutcome, ManagedGroupPacing, ManagedTargetConfig, ManagedTargetEndReason,
|
||||||
|
ManagedTargetOutcome, TargetEvent, TargetEventSubscription, TargetId,
|
||||||
|
};
|
||||||
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
|
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
|
||||||
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
|
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
|
||||||
@@ -21,6 +21,12 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
|
|||||||
const IDLE_SLEEP: Duration = Duration::from_millis(1);
|
const IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// Entry point for running a client session on a worker thread.
|
||||||
|
///
|
||||||
|
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
|
||||||
|
/// opens the session, sends probes at the negotiated interval, receives
|
||||||
|
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
|
||||||
|
/// run completes or is cancelled.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ManagedClient;
|
pub struct ManagedClient;
|
||||||
|
|
||||||
@@ -46,26 +52,49 @@ pub struct ManagedClientSession {
|
|||||||
#[must_use = "managed session outcomes contain completion status and counters"]
|
#[must_use = "managed session outcomes contain completion status and counters"]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SessionOutcome {
|
pub struct SessionOutcome {
|
||||||
|
/// Why the managed loop stopped.
|
||||||
pub end_reason: SessionEndReason,
|
pub end_reason: SessionEndReason,
|
||||||
|
/// Number of echo requests sent by the client.
|
||||||
pub packets_sent: u64,
|
pub packets_sent: u64,
|
||||||
|
/// Number of first in-window echo replies received.
|
||||||
pub replies_received: u64,
|
pub replies_received: u64,
|
||||||
|
/// Number of duplicate reply events emitted.
|
||||||
pub duplicates: u64,
|
pub duplicates: u64,
|
||||||
|
/// Number of late reply events emitted.
|
||||||
pub late: u64,
|
pub late: u64,
|
||||||
|
/// Number of warning events emitted.
|
||||||
pub warning_events: u64,
|
pub warning_events: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reason a managed client session ended.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SessionEndReason {
|
pub enum SessionEndReason {
|
||||||
|
/// The negotiated finite test duration completed and the client closed the
|
||||||
|
/// session normally.
|
||||||
TestComplete,
|
TestComplete,
|
||||||
|
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
|
||||||
|
/// dropping the session handle.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
|
||||||
|
/// completed after negotiation.
|
||||||
NoTestComplete,
|
NoTestComplete,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedClient {
|
impl ManagedClient {
|
||||||
|
/// Start a managed session without creating an initial event subscription.
|
||||||
|
///
|
||||||
|
/// The returned [`ManagedClientSession`] can still create subscriptions via
|
||||||
|
/// [`ManagedClientSession::subscribe`], but events emitted before a
|
||||||
|
/// subscriber is registered are not replayed.
|
||||||
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
|
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
|
||||||
Self::start_inner(config, None).map(|(session, _)| session)
|
Self::start_inner(config, None).map(|(session, _)| session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a managed session and subscribe to events before the worker runs.
|
||||||
|
///
|
||||||
|
/// The initial subscription receives the open lifecycle event and
|
||||||
|
/// subsequent session events, subject to its queue capacity and overflow
|
||||||
|
/// policy.
|
||||||
pub fn start_with_subscription(
|
pub fn start_with_subscription(
|
||||||
config: ClientConfig,
|
config: ClientConfig,
|
||||||
subscriber_config: SubscriberConfig,
|
subscriber_config: SubscriberConfig,
|
||||||
@@ -114,14 +143,28 @@ impl ManagedClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedClientSession {
|
impl ManagedClientSession {
|
||||||
|
/// Add another event subscriber to this running managed session.
|
||||||
|
///
|
||||||
|
/// The subscription receives only events published after it is registered.
|
||||||
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
|
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
|
||||||
self.hub.subscribe(config)
|
self.hub.subscribe(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request cooperative cancellation of the managed session.
|
||||||
|
///
|
||||||
|
/// Cancellation is observed by the worker loop between socket receive,
|
||||||
|
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
|
||||||
|
/// wait for the worker and obtain the final [`SessionOutcome`].
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
self.cancellation.cancel();
|
self.cancellation.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for the managed worker thread to finish.
|
||||||
|
///
|
||||||
|
/// On success, returns lifecycle counters for the completed session. If the
|
||||||
|
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
|
||||||
|
/// also disconnects all event subscriptions while leaving already queued
|
||||||
|
/// events available to drain.
|
||||||
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
|
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
|
||||||
let worker = self
|
let worker = self
|
||||||
.worker
|
.worker
|
||||||
@@ -181,7 +224,9 @@ fn run_client(
|
|||||||
&mut counters,
|
&mut counters,
|
||||||
client.recv_available(MANAGED_RECV_BUDGET)?,
|
client.recv_available(MANAGED_RECV_BUDGET)?,
|
||||||
);
|
);
|
||||||
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
if !client.is_peer_closed() {
|
||||||
|
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +245,9 @@ fn run_client(
|
|||||||
&mut counters,
|
&mut counters,
|
||||||
client.recv_available(MANAGED_RECV_BUDGET)?,
|
client.recv_available(MANAGED_RECV_BUDGET)?,
|
||||||
);
|
);
|
||||||
|
if client.is_peer_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
||||||
|
|
||||||
if client.is_run_complete() {
|
if client.is_run_complete() {
|
||||||
@@ -214,8 +262,10 @@ fn run_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let packets_sent = client.packets_sent();
|
let packets_sent = client.packets_sent();
|
||||||
let close_events = client.close()?;
|
if !client.is_peer_closed() {
|
||||||
publish_events(&hub, &mut counters, close_events);
|
let close_events = client.close()?;
|
||||||
|
publish_events(&hub, &mut counters, close_events);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SessionOutcome {
|
Ok(SessionOutcome {
|
||||||
end_reason: if cancelled {
|
end_reason: if cancelled {
|
||||||
@@ -252,12 +302,20 @@ fn drain_final_late_replies(
|
|||||||
|
|
||||||
let deadline = Instant::now() + MANAGED_FINAL_DRAIN;
|
let deadline = Instant::now() + MANAGED_FINAL_DRAIN;
|
||||||
while Instant::now() < deadline && client.has_timed_out_metadata() {
|
while Instant::now() < deadline && client.has_timed_out_metadata() {
|
||||||
|
if client.is_peer_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let mut published = false;
|
let mut published = false;
|
||||||
|
|
||||||
let events = client.recv_available(MANAGED_RECV_BUDGET)?;
|
let events = client.recv_available(MANAGED_RECV_BUDGET)?;
|
||||||
published |= !events.is_empty();
|
published |= !events.is_empty();
|
||||||
publish_events(hub, counters, events);
|
publish_events(hub, counters, events);
|
||||||
|
|
||||||
|
if client.is_peer_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let events = client.poll_timeouts()?;
|
let events = client.poll_timeouts()?;
|
||||||
published |= !events.is_empty();
|
published |= !events.is_empty();
|
||||||
publish_events(hub, counters, events);
|
publish_events(hub, counters, events);
|
||||||
@@ -438,6 +496,53 @@ mod tests {
|
|||||||
FakeServer { addr, done }
|
FakeServer { addr, done }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_peer_close_server(params: Params) -> FakeServer {
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = socket.local_addr().unwrap();
|
||||||
|
let done = thread::spawn(move || {
|
||||||
|
let (_, peer) = recv_request(&socket);
|
||||||
|
socket
|
||||||
|
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||||
|
.unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Some((packet, peer)) = recv_request_timeout(&socket) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||||
|
let ts = TimestampFields {
|
||||||
|
recv_wall: Some(1_000_000_000),
|
||||||
|
recv_mono: Some(100_000),
|
||||||
|
send_wall: Some(1_000_000_000),
|
||||||
|
send_mono: Some(100_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
socket
|
||||||
|
.send_to(
|
||||||
|
&echo_reply_packet_with_flags(
|
||||||
|
TOKEN,
|
||||||
|
seq,
|
||||||
|
¶ms,
|
||||||
|
&ts,
|
||||||
|
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||||
|
),
|
||||||
|
peer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if let Some((packet, _)) = recv_request_timeout(&socket) {
|
||||||
|
panic!(
|
||||||
|
"managed cleanup must not send any packet after peer close; flags={:?} len={}",
|
||||||
|
packet.get(3).copied(),
|
||||||
|
packet.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeServer { addr, done }
|
||||||
|
}
|
||||||
|
|
||||||
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
||||||
let mut buf = [0_u8; 2048];
|
let mut buf = [0_u8; 2048];
|
||||||
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
||||||
@@ -466,13 +571,23 @@ mod tests {
|
|||||||
seq: u32,
|
seq: u32,
|
||||||
params: &Params,
|
params: &Params,
|
||||||
timestamps: &TimestampFields,
|
timestamps: &TimestampFields,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
echo_reply_packet_with_flags(token, seq, params, timestamps, FLAG_REPLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_reply_packet_with_flags(
|
||||||
|
token: u64,
|
||||||
|
seq: u32,
|
||||||
|
params: &Params,
|
||||||
|
timestamps: &TimestampFields,
|
||||||
|
flags: u8,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let layout = PacketLayout::echo(false, params);
|
let layout = PacketLayout::echo(false, params);
|
||||||
let packet_len = test_echo_packet_len(false, params);
|
let packet_len = test_echo_packet_len(false, params);
|
||||||
let mut packet = Vec::with_capacity(packet_len);
|
let mut packet = Vec::with_capacity(packet_len);
|
||||||
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
packet.extend_from_slice(&MAGIC);
|
||||||
packet.push(FLAG_REPLY);
|
packet.push(flags);
|
||||||
packet.extend_from_slice(&token.to_le_bytes());
|
packet.extend_from_slice(&token.to_le_bytes());
|
||||||
packet.extend_from_slice(&seq.to_le_bytes());
|
packet.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
|
||||||
@@ -624,6 +739,31 @@ mod tests {
|
|||||||
assert!(late_before_close < close);
|
assert!(late_before_close < close);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_close_during_managed_run_is_successful() {
|
||||||
|
let server = start_peer_close_server(test_params(None, Duration::from_millis(10)));
|
||||||
|
let (session, sub) = ManagedClient::start_with_subscription(
|
||||||
|
config(server.addr, None),
|
||||||
|
SubscriberConfig {
|
||||||
|
capacity: 16,
|
||||||
|
overflow: SubscriberOverflow::DropNewest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let events = collect_until_closed(&sub);
|
||||||
|
let outcome = session.join().unwrap();
|
||||||
|
server.join();
|
||||||
|
|
||||||
|
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn continuous_managed_run_can_be_stopped_cleanly() {
|
fn continuous_managed_run_can_be_stopped_cleanly() {
|
||||||
let server = start_echo_server(test_params(None, Duration::from_millis(10)));
|
let server = start_echo_server(test_params(None, Duration::from_millis(10)));
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ mod tests {
|
|||||||
use super::ReceiveMeta;
|
use super::ReceiveMeta;
|
||||||
use crate::event::PacketMeta;
|
use crate::event::PacketMeta;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn metadata_default_is_unavailable() {
|
|
||||||
let meta = ReceiveMeta::default();
|
|
||||||
let packet_meta = PacketMeta::from(meta);
|
|
||||||
|
|
||||||
assert_eq!(packet_meta.traffic_class, None);
|
|
||||||
assert_eq!(packet_meta.dscp, None);
|
|
||||||
assert_eq!(packet_meta.ecn, None);
|
|
||||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
|
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
|
||||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||||
|
|||||||
@@ -193,18 +193,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_map_insert_and_remove() {
|
|
||||||
let mut map = PendingMap::new(10);
|
|
||||||
let now = Instant::now();
|
|
||||||
let probe = pending(0, now + Duration::from_secs(4));
|
|
||||||
map.insert(probe).unwrap();
|
|
||||||
assert_eq!(map.len(), 1);
|
|
||||||
assert!(map.remove(0).is_some());
|
|
||||||
assert_eq!(map.len(), 0);
|
|
||||||
assert!(map.remove(0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_map_rejects_over_capacity() {
|
fn pending_map_rejects_over_capacity() {
|
||||||
let mut map = PendingMap::new(2);
|
let mut map = PendingMap::new(2);
|
||||||
@@ -220,7 +208,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completed_set_tracks_and_evicts() {
|
fn bounded_probe_tracking_evicts_oldest_entries() {
|
||||||
let mut set = CompletedSet::new(3);
|
let mut set = CompletedSet::new(3);
|
||||||
set.insert(0);
|
set.insert(0);
|
||||||
set.insert(1);
|
set.insert(1);
|
||||||
@@ -232,10 +220,7 @@ mod tests {
|
|||||||
assert_eq!(set.set.len(), 3);
|
assert_eq!(set.set.len(), 3);
|
||||||
assert!(!set.contains(0));
|
assert!(!set.contains(0));
|
||||||
assert!(set.contains(3));
|
assert!(set.contains(3));
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timed_out_map_tracks_and_evicts() {
|
|
||||||
let mut map = TimedOutMap::new(2);
|
let mut map = TimedOutMap::new(2);
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
map.insert(pending(0, now));
|
map.insert(pending(0, now));
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ pub(crate) struct ReceivedDatagram {
|
|||||||
pub(crate) meta: ReceiveMeta,
|
pub(crate) meta: ReceiveMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ReceivedDatagramFrom {
|
||||||
|
pub(crate) len: usize,
|
||||||
|
pub(crate) source: SocketAddr,
|
||||||
|
pub(crate) received_at: ClientTimestamp,
|
||||||
|
pub(crate) meta: ReceiveMeta,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||||
pub(crate) fn recv_datagram(
|
pub(crate) fn recv_datagram(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
@@ -30,6 +38,22 @@ pub(crate) fn recv_datagram(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||||
|
pub(crate) fn recv_datagram_from(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
buf: &mut [u8],
|
||||||
|
) -> Result<ReceivedDatagramFrom, io::Error> {
|
||||||
|
let (len, source) = socket.recv_from(buf)?;
|
||||||
|
let received_at = ClientTimestamp::now();
|
||||||
|
|
||||||
|
Ok(ReceivedDatagramFrom {
|
||||||
|
len,
|
||||||
|
source,
|
||||||
|
received_at,
|
||||||
|
meta: ReceiveMeta::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||||
pub(crate) fn recv_datagram(
|
pub(crate) fn recv_datagram(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
@@ -38,6 +62,14 @@ pub(crate) fn recv_datagram(
|
|||||||
linux::recv_datagram(socket, buf)
|
linux::recv_datagram(socket, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||||
|
pub(crate) fn recv_datagram_from(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
buf: &mut [u8],
|
||||||
|
) -> Result<ReceivedDatagramFrom, io::Error> {
|
||||||
|
linux::recv_datagram_from(socket, buf)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||||
pub(crate) fn configure_receive_metadata(
|
pub(crate) fn configure_receive_metadata(
|
||||||
_socket: &UdpSocket,
|
_socket: &UdpSocket,
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
#![allow(unsafe_code)]
|
#![allow(unsafe_code)]
|
||||||
use std::{
|
use std::{
|
||||||
io, mem,
|
io, mem,
|
||||||
net::{SocketAddr, UdpSocket},
|
net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6, UdpSocket},
|
||||||
os::fd::AsRawFd,
|
os::fd::AsRawFd,
|
||||||
ptr,
|
ptr,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{metadata::ReceiveMeta, receive::ReceivedDatagram, timing::ClientTimestamp};
|
use crate::{
|
||||||
|
metadata::ReceiveMeta,
|
||||||
|
receive::{ReceivedDatagram, ReceivedDatagramFrom},
|
||||||
|
timing::ClientTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
const CONTROL_LEN: usize = 128;
|
const CONTROL_LEN: usize = 128;
|
||||||
|
|
||||||
@@ -87,6 +91,61 @@ pub(crate) fn recv_datagram(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn recv_datagram_from(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
buf: &mut [u8],
|
||||||
|
) -> Result<ReceivedDatagramFrom, io::Error> {
|
||||||
|
let mut control = ControlBuffer::new();
|
||||||
|
let mut name: libc::sockaddr_storage = unsafe { mem::zeroed() };
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: buf.as_mut_ptr().cast(),
|
||||||
|
iov_len: buf.len(),
|
||||||
|
};
|
||||||
|
let mut msg: libc::msghdr = unsafe { mem::zeroed() };
|
||||||
|
msg.msg_name = (&mut name as *mut libc::sockaddr_storage).cast();
|
||||||
|
msg.msg_namelen = mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
|
||||||
|
msg.msg_iov = &mut iov;
|
||||||
|
msg.msg_iovlen = 1;
|
||||||
|
msg.msg_control = control.as_mut_ptr().cast();
|
||||||
|
msg.msg_controllen = control.len();
|
||||||
|
|
||||||
|
let len = unsafe {
|
||||||
|
// SAFETY: `msg` points to one writable iovec backed by `buf`, a
|
||||||
|
// writable source-address buffer, and a writable control buffer. All
|
||||||
|
// buffers live until `recvmsg` returns. The socket file descriptor is
|
||||||
|
// borrowed from a valid `UdpSocket`.
|
||||||
|
libc::recvmsg(socket.as_raw_fd(), &mut msg, 0)
|
||||||
|
};
|
||||||
|
if len < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let len = usize::try_from(len).map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"recvmsg returned an unrepresentable datagram length",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let received_at = ClientTimestamp::now();
|
||||||
|
let source = unsafe {
|
||||||
|
// SAFETY: `name` and `msg_namelen` were initialized by a successful
|
||||||
|
// `recvmsg` call above.
|
||||||
|
socket_addr_from_storage(&name, msg.msg_namelen)?
|
||||||
|
};
|
||||||
|
let meta = unsafe {
|
||||||
|
// SAFETY: `msg` was initialized by a successful `recvmsg` call. The
|
||||||
|
// parser only reads cmsghdr entries within `msg_controllen` and ignores
|
||||||
|
// short or unrelated control messages.
|
||||||
|
parse_receive_meta(&msg)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ReceivedDatagramFrom {
|
||||||
|
len,
|
||||||
|
source,
|
||||||
|
received_at,
|
||||||
|
meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
|
unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
|
||||||
let mut meta = ReceiveMeta::default();
|
let mut meta = ReceiveMeta::default();
|
||||||
let mut cmsg = libc::CMSG_FIRSTHDR(msg);
|
let mut cmsg = libc::CMSG_FIRSTHDR(msg);
|
||||||
@@ -108,6 +167,52 @@ unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
|
|||||||
meta
|
meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsafe fn socket_addr_from_storage(
|
||||||
|
storage: &libc::sockaddr_storage,
|
||||||
|
len: libc::socklen_t,
|
||||||
|
) -> io::Result<SocketAddr> {
|
||||||
|
match i32::from(storage.ss_family) {
|
||||||
|
libc::AF_INET => {
|
||||||
|
if usize::try_from(len).unwrap_or(0) < mem::size_of::<libc::sockaddr_in>() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"short IPv4 source address from recvmsg",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let addr = ptr::read_unaligned(
|
||||||
|
(storage as *const libc::sockaddr_storage).cast::<libc::sockaddr_in>(),
|
||||||
|
);
|
||||||
|
let ip = Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr));
|
||||||
|
let port = u16::from_be(addr.sin_port);
|
||||||
|
Ok(SocketAddr::V4(SocketAddrV4::new(ip, port)))
|
||||||
|
}
|
||||||
|
libc::AF_INET6 => {
|
||||||
|
if usize::try_from(len).unwrap_or(0) < mem::size_of::<libc::sockaddr_in6>() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"short IPv6 source address from recvmsg",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let addr = ptr::read_unaligned(
|
||||||
|
(storage as *const libc::sockaddr_storage).cast::<libc::sockaddr_in6>(),
|
||||||
|
);
|
||||||
|
let ip = Ipv6Addr::from(addr.sin6_addr.s6_addr);
|
||||||
|
let port = u16::from_be(addr.sin6_port);
|
||||||
|
let flowinfo = u32::from_be(addr.sin6_flowinfo);
|
||||||
|
Ok(SocketAddr::V6(SocketAddrV6::new(
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
flowinfo,
|
||||||
|
addr.sin6_scope_id,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
family => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("unsupported source address family from recvmsg: {family}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_traffic_class_cmsg(cmsg: &libc::cmsghdr) -> bool {
|
fn is_traffic_class_cmsg(cmsg: &libc::cmsghdr) -> bool {
|
||||||
(cmsg.cmsg_level == libc::IPPROTO_IP && cmsg.cmsg_type == libc::IP_TOS)
|
(cmsg.cmsg_level == libc::IPPROTO_IP && cmsg.cmsg_type == libc::IP_TOS)
|
||||||
|| (cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_TCLASS)
|
|| (cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_TCLASS)
|
||||||
|
|||||||
@@ -0,0 +1,864 @@
|
|||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use irtt_proto::{
|
||||||
|
close::CloseRequest, decode_echo_reply, echo_packet_len, encode_close_request,
|
||||||
|
encode_echo_request, encode_open_request, flags, EchoReply, EchoRequest, OpenReply,
|
||||||
|
OpenRequest, Params, ServerFill, TimestampFields, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{
|
||||||
|
ClientConfig, RunMode, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_UDP_PAYLOAD_LENGTH,
|
||||||
|
},
|
||||||
|
error::ClientError,
|
||||||
|
event::{
|
||||||
|
ClientEvent, OneWayDelaySample, OpenOutcome, ReceivedStatsSample, RttSample, ServerTiming,
|
||||||
|
SignedDuration, WarningKind,
|
||||||
|
},
|
||||||
|
metadata::ReceiveMeta,
|
||||||
|
probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
|
||||||
|
session::{negotiate_params, ActiveSession, ClientPhase, CloseSource, NegotiatedParams},
|
||||||
|
timing::ClientTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const MAX_OPEN_PACKET_SIZE: usize = 512;
|
||||||
|
const MIN_RECV_BUFFER_SIZE: usize = 2048;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct SessionRuntime {
|
||||||
|
config: ClientConfig,
|
||||||
|
remote: std::net::SocketAddr,
|
||||||
|
requested: Params,
|
||||||
|
negotiated: Option<NegotiatedParams>,
|
||||||
|
phase: ClientPhase,
|
||||||
|
session: Option<ActiveSession>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct SendProbeResult {
|
||||||
|
pub(crate) sent_at: ClientTimestamp,
|
||||||
|
pub(crate) bytes: usize,
|
||||||
|
pub(crate) send_call: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRuntime {
|
||||||
|
pub(crate) fn new(
|
||||||
|
config: ClientConfig,
|
||||||
|
remote: std::net::SocketAddr,
|
||||||
|
) -> Result<Self, ClientError> {
|
||||||
|
if config.max_pending_probes == 0 {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "max_pending_probes must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.probe_timeout == Duration::ZERO {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "probe_timeout must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let requested = params_from_config(&config)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
remote,
|
||||||
|
requested,
|
||||||
|
negotiated: None,
|
||||||
|
phase: ClientPhase::Connected,
|
||||||
|
session: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn config(&self) -> &ClientConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_hmac(&self) -> bool {
|
||||||
|
self.config.hmac_key.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_packet(&self) -> Result<Vec<u8>, ClientError> {
|
||||||
|
match self.phase {
|
||||||
|
ClientPhase::Connected => {}
|
||||||
|
ClientPhase::Open { .. } => return Err(ClientError::AlreadyOpen),
|
||||||
|
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
|
||||||
|
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = OpenRequest {
|
||||||
|
params: self.requested.clone(),
|
||||||
|
close: self.config.run_mode == RunMode::NoTest,
|
||||||
|
};
|
||||||
|
Ok(encode_open_request(
|
||||||
|
&request,
|
||||||
|
self.config.hmac_key.as_deref(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_open_reply(&self, packet: &[u8]) -> Result<OpenReply, ClientError> {
|
||||||
|
Ok(irtt_proto::decode_open_reply(
|
||||||
|
packet,
|
||||||
|
self.config.hmac_key.as_deref(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn accept_open_reply<F>(
|
||||||
|
&mut self,
|
||||||
|
reply: OpenReply,
|
||||||
|
now: ClientTimestamp,
|
||||||
|
before_normal_open: F,
|
||||||
|
) -> Result<OpenOutcome, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&NegotiatedParams) -> Result<(), ClientError>,
|
||||||
|
{
|
||||||
|
if reply.params.protocol_version != PROTOCOL_VERSION {
|
||||||
|
return Err(ClientError::ProtocolVersionMismatch {
|
||||||
|
requested: PROTOCOL_VERSION,
|
||||||
|
received: reply.params.protocol_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply_is_close = flags::has(reply.flags, flags::FLAG_CLOSE);
|
||||||
|
match self.config.run_mode {
|
||||||
|
RunMode::Normal if reply_is_close => Err(ClientError::ServerRejected),
|
||||||
|
RunMode::Normal if reply.token == 0 => Err(ClientError::ZeroToken),
|
||||||
|
RunMode::Normal => self.accept_normal_open(reply, now, before_normal_open),
|
||||||
|
RunMode::NoTest if !reply_is_close => Err(ClientError::UnexpectedNoTestReply),
|
||||||
|
RunMode::NoTest if reply.token != 0 => {
|
||||||
|
Err(ClientError::NonZeroNoTestToken { token: reply.token })
|
||||||
|
}
|
||||||
|
RunMode::NoTest => self.accept_no_test_open(reply, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||||
|
let session = self.session.as_ref()?;
|
||||||
|
if session.sending_done {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(session.next_send_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn probe_timeout(&self) -> Duration {
|
||||||
|
self.config.probe_timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn send_probe_with<F>(
|
||||||
|
&mut self,
|
||||||
|
override_ts: Option<ClientTimestamp>,
|
||||||
|
send: F,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
|
||||||
|
{
|
||||||
|
self.send_probe_inner(override_ts, None, send)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn send_probe_for_deadline<F>(
|
||||||
|
&mut self,
|
||||||
|
scheduled_at: Instant,
|
||||||
|
send: F,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
|
||||||
|
{
|
||||||
|
self.send_probe_inner(None, Some(scheduled_at), send)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_probe_inner<F>(
|
||||||
|
&mut self,
|
||||||
|
override_ts: Option<ClientTimestamp>,
|
||||||
|
scheduled_at_override: Option<Instant>,
|
||||||
|
send: F,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> Result<SendProbeResult, ClientError>,
|
||||||
|
{
|
||||||
|
let token = match self.phase {
|
||||||
|
ClientPhase::Open { token } => token,
|
||||||
|
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
|
||||||
|
ClientPhase::Connected => return Err(ClientError::NotOpen),
|
||||||
|
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.expect("session must exist when phase is Open");
|
||||||
|
|
||||||
|
if session.sending_done {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = override_ts.unwrap_or_else(ClientTimestamp::now);
|
||||||
|
|
||||||
|
if let Some(end) = session.end_mono {
|
||||||
|
if now.mono >= end {
|
||||||
|
session.sending_done = true;
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.pending.check_capacity()?;
|
||||||
|
|
||||||
|
let negotiated = self
|
||||||
|
.negotiated
|
||||||
|
.as_ref()
|
||||||
|
.expect("negotiated must exist when Open");
|
||||||
|
|
||||||
|
let wire_seq = session.next_wire_seq;
|
||||||
|
let scheduled_at = scheduled_at_override.unwrap_or(session.next_send_at);
|
||||||
|
|
||||||
|
let request = EchoRequest {
|
||||||
|
token,
|
||||||
|
sequence: wire_seq,
|
||||||
|
params: negotiated.params.clone(),
|
||||||
|
payload: vec![],
|
||||||
|
};
|
||||||
|
let packet = encode_echo_request(&request, self.config.hmac_key.as_deref())?;
|
||||||
|
let send_result = send(&packet)?;
|
||||||
|
let timer_error = instant_abs_diff(send_result.sent_at.mono, scheduled_at);
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.expect("session must exist when phase is Open");
|
||||||
|
|
||||||
|
let pending = PendingProbe {
|
||||||
|
wire_seq,
|
||||||
|
sent_at: send_result.sent_at,
|
||||||
|
timeout_at: send_result
|
||||||
|
.sent_at
|
||||||
|
.mono
|
||||||
|
.checked_add(self.config.probe_timeout)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?,
|
||||||
|
};
|
||||||
|
session.pending.insert(pending)?;
|
||||||
|
|
||||||
|
session.next_wire_seq = session.next_wire_seq.wrapping_add(1);
|
||||||
|
session.packets_sent =
|
||||||
|
session
|
||||||
|
.packets_sent
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ClientError::CounterOverflow {
|
||||||
|
counter: "packets_sent",
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let negotiated = self
|
||||||
|
.negotiated
|
||||||
|
.as_ref()
|
||||||
|
.expect("negotiated must exist when Open");
|
||||||
|
let interval_ns = u64::try_from(negotiated.params.interval_ns)
|
||||||
|
.expect("validated positive negotiated interval");
|
||||||
|
let session = self
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.expect("session must exist when phase is Open");
|
||||||
|
session.next_send_at = if let Some(scheduled_at) = scheduled_at_override {
|
||||||
|
scheduled_at
|
||||||
|
.checked_add(Duration::from_nanos(interval_ns))
|
||||||
|
.ok_or(ClientError::DurationOverflow)?
|
||||||
|
} else {
|
||||||
|
next_probe_deadline(session.start_mono, interval_ns, session.packets_sent)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(end) = session.end_mono {
|
||||||
|
if session.next_send_at >= end {
|
||||||
|
session.sending_done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![ClientEvent::EchoSent {
|
||||||
|
seq: wire_seq,
|
||||||
|
remote: self.remote,
|
||||||
|
scheduled_at,
|
||||||
|
sent_at: send_result.sent_at,
|
||||||
|
bytes: send_result.bytes,
|
||||||
|
send_call: send_result.send_call,
|
||||||
|
timer_error,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn process_received_echo_packet(
|
||||||
|
&mut self,
|
||||||
|
packet: &[u8],
|
||||||
|
now: ClientTimestamp,
|
||||||
|
meta: ReceiveMeta,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
|
match self.phase {
|
||||||
|
ClientPhase::Open { .. } => {}
|
||||||
|
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
|
||||||
|
ClientPhase::Connected => return Err(ClientError::NotOpen),
|
||||||
|
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(reply) = self.decode_received_packet(packet) else {
|
||||||
|
return Ok(vec![ClientEvent::Warning {
|
||||||
|
kind: WarningKind::MalformedOrUnrelatedPacket,
|
||||||
|
message: "dropped malformed or unrelated packet".to_owned(),
|
||||||
|
at: now,
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
self.process_echo_reply(reply, packet.len(), now, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn poll_timeouts_at(
|
||||||
|
&mut self,
|
||||||
|
now: Instant,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
|
match self.phase {
|
||||||
|
ClientPhase::Open { .. } => {}
|
||||||
|
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
|
||||||
|
ClientPhase::Connected => return Err(ClientError::NotOpen),
|
||||||
|
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.expect("session must exist when phase is Open");
|
||||||
|
|
||||||
|
let expired = session.pending.drain_expired(now);
|
||||||
|
let mut events = Vec::with_capacity(expired.len());
|
||||||
|
for probe in expired {
|
||||||
|
events.push(ClientEvent::EchoLoss {
|
||||||
|
seq: probe.wire_seq,
|
||||||
|
sent_at: probe.sent_at,
|
||||||
|
timeout_at: probe.timeout_at,
|
||||||
|
});
|
||||||
|
session.timed_out.insert(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn close_with<F>(&mut self, send: F) -> Result<Vec<ClientEvent>, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> Result<(), ClientError>,
|
||||||
|
{
|
||||||
|
let token = match self.phase {
|
||||||
|
ClientPhase::Open { token } => token,
|
||||||
|
ClientPhase::Closed { .. } => return Err(ClientError::AlreadyClosed),
|
||||||
|
ClientPhase::Connected | ClientPhase::NoTestCompleted => {
|
||||||
|
return Err(ClientError::NotOpen)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let packet =
|
||||||
|
encode_close_request(&CloseRequest { token }, self.config.hmac_key.as_deref())?;
|
||||||
|
send(&packet)?;
|
||||||
|
self.phase = ClientPhase::Closed {
|
||||||
|
source: CloseSource::Local,
|
||||||
|
};
|
||||||
|
if let Some(session) = self.session.as_mut() {
|
||||||
|
session.timed_out.clear();
|
||||||
|
}
|
||||||
|
self.session = None;
|
||||||
|
|
||||||
|
Ok(vec![ClientEvent::SessionClosed {
|
||||||
|
remote: self.remote,
|
||||||
|
token,
|
||||||
|
at: ClientTimestamp::now(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_run_complete(&self) -> bool {
|
||||||
|
let Some(session) = self.session.as_ref() else {
|
||||||
|
return matches!(
|
||||||
|
self.phase,
|
||||||
|
ClientPhase::Closed { .. } | ClientPhase::NoTestCompleted
|
||||||
|
);
|
||||||
|
};
|
||||||
|
session.sending_done && session.pending.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_peer_closed(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.phase,
|
||||||
|
ClientPhase::Closed {
|
||||||
|
source: CloseSource::Peer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_timed_out_metadata(&self) -> bool {
|
||||||
|
self.session
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|session| session.timed_out.len() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn packets_sent(&self) -> u64 {
|
||||||
|
self.session
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |session| session.packets_sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_open(&self) -> bool {
|
||||||
|
matches!(self.phase, ClientPhase::Open { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_normal_open<F>(
|
||||||
|
&mut self,
|
||||||
|
reply: OpenReply,
|
||||||
|
now: ClientTimestamp,
|
||||||
|
before_normal_open: F,
|
||||||
|
) -> Result<OpenOutcome, ClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&NegotiatedParams) -> Result<(), ClientError>,
|
||||||
|
{
|
||||||
|
let token = reply.token;
|
||||||
|
let negotiated = negotiate_params(
|
||||||
|
&self.requested,
|
||||||
|
reply.params,
|
||||||
|
self.config.negotiation_policy,
|
||||||
|
)?;
|
||||||
|
before_normal_open(&negotiated)?;
|
||||||
|
self.negotiated = Some(negotiated.clone());
|
||||||
|
self.phase = ClientPhase::Open { token };
|
||||||
|
|
||||||
|
let end_mono = if negotiated.params.duration_ns > 0 {
|
||||||
|
Some(negotiated_end_mono(
|
||||||
|
now.mono,
|
||||||
|
negotiated.params.duration_ns,
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.session = Some(ActiveSession {
|
||||||
|
next_wire_seq: 0,
|
||||||
|
highest_received_seq: None,
|
||||||
|
packets_sent: 0,
|
||||||
|
start_mono: now.mono,
|
||||||
|
end_mono,
|
||||||
|
next_send_at: now.mono,
|
||||||
|
pending: PendingMap::new(self.config.max_pending_probes),
|
||||||
|
timed_out: TimedOutMap::new(self.config.max_pending_probes),
|
||||||
|
completed: CompletedSet::new(self.config.max_pending_probes),
|
||||||
|
sending_done: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = ClientEvent::SessionStarted {
|
||||||
|
remote: self.remote,
|
||||||
|
token,
|
||||||
|
negotiated: negotiated.clone(),
|
||||||
|
at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(OpenOutcome::Started {
|
||||||
|
remote: self.remote,
|
||||||
|
token,
|
||||||
|
negotiated,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_no_test_open(
|
||||||
|
&mut self,
|
||||||
|
reply: OpenReply,
|
||||||
|
now: ClientTimestamp,
|
||||||
|
) -> Result<OpenOutcome, ClientError> {
|
||||||
|
let negotiated = negotiate_params(
|
||||||
|
&self.requested,
|
||||||
|
reply.params,
|
||||||
|
self.config.negotiation_policy,
|
||||||
|
)?;
|
||||||
|
self.negotiated = Some(negotiated.clone());
|
||||||
|
self.phase = ClientPhase::NoTestCompleted;
|
||||||
|
let event = ClientEvent::NoTestCompleted {
|
||||||
|
remote: self.remote,
|
||||||
|
negotiated: negotiated.clone(),
|
||||||
|
at: now,
|
||||||
|
};
|
||||||
|
Ok(OpenOutcome::NoTestCompleted {
|
||||||
|
remote: self.remote,
|
||||||
|
negotiated,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
|
||||||
|
let negotiated = self
|
||||||
|
.negotiated
|
||||||
|
.as_ref()
|
||||||
|
.expect("negotiated must exist when Open");
|
||||||
|
|
||||||
|
decode_echo_reply(packet, &negotiated.params, self.config.hmac_key.as_deref()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_echo_reply(
|
||||||
|
&mut self,
|
||||||
|
reply: EchoReply,
|
||||||
|
packet_len: usize,
|
||||||
|
now: ClientTimestamp,
|
||||||
|
meta: ReceiveMeta,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
|
let token = match self.phase {
|
||||||
|
ClientPhase::Open { token } => token,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if reply.token != token {
|
||||||
|
return Ok(vec![ClientEvent::Warning {
|
||||||
|
kind: WarningKind::WrongToken,
|
||||||
|
message: format!(
|
||||||
|
"dropped reply with wrong token: expected {token:#x}, got {:#x}",
|
||||||
|
reply.token
|
||||||
|
),
|
||||||
|
at: now,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = self.session.as_mut().expect("session must exist when Open");
|
||||||
|
|
||||||
|
let wire_seq = reply.sequence;
|
||||||
|
|
||||||
|
if let Some(pending) = session.pending.remove(wire_seq) {
|
||||||
|
let rtt = compute_rtt(&pending.sent_at, &now, &reply.timestamps);
|
||||||
|
let server_timing = build_server_timing(&reply.timestamps);
|
||||||
|
let one_way = compute_one_way(&pending.sent_at, &now, &reply.timestamps);
|
||||||
|
let received_stats = build_received_stats(&reply);
|
||||||
|
let is_late = session
|
||||||
|
.highest_received_seq
|
||||||
|
.is_some_and(|h| sequence_is_before(wire_seq, h));
|
||||||
|
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
||||||
|
|
||||||
|
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
||||||
|
session.completed.insert(wire_seq);
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if is_late {
|
||||||
|
events.push(ClientEvent::LateReply {
|
||||||
|
seq: wire_seq,
|
||||||
|
highest_seen,
|
||||||
|
remote: self.remote,
|
||||||
|
sent_at: Some(pending.sent_at),
|
||||||
|
received_at: now,
|
||||||
|
rtt: Some(rtt),
|
||||||
|
server_timing,
|
||||||
|
one_way,
|
||||||
|
received_stats,
|
||||||
|
bytes: packet_len,
|
||||||
|
packet_meta: meta.into(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
events.push(ClientEvent::EchoReply {
|
||||||
|
seq: wire_seq,
|
||||||
|
remote: self.remote,
|
||||||
|
sent_at: pending.sent_at,
|
||||||
|
received_at: now,
|
||||||
|
rtt,
|
||||||
|
server_timing,
|
||||||
|
one_way,
|
||||||
|
received_stats,
|
||||||
|
bytes: packet_len,
|
||||||
|
packet_meta: meta.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if flags::has(reply.flags, flags::FLAG_CLOSE) {
|
||||||
|
self.close_from_peer(token, now, &mut events);
|
||||||
|
}
|
||||||
|
Ok(events)
|
||||||
|
} else if session.completed.contains(wire_seq) {
|
||||||
|
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
||||||
|
Ok(vec![ClientEvent::DuplicateReply {
|
||||||
|
seq: wire_seq,
|
||||||
|
remote: self.remote,
|
||||||
|
received_at: now,
|
||||||
|
bytes: packet_len,
|
||||||
|
}])
|
||||||
|
} else if let Some(timed_out) = session.timed_out.remove(wire_seq) {
|
||||||
|
let rtt = compute_rtt(&timed_out.sent_at, &now, &reply.timestamps);
|
||||||
|
let server_timing = build_server_timing(&reply.timestamps);
|
||||||
|
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
|
||||||
|
let received_stats = build_received_stats(&reply);
|
||||||
|
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
||||||
|
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
||||||
|
session.completed.insert(wire_seq);
|
||||||
|
|
||||||
|
let mut events = vec![ClientEvent::LateReply {
|
||||||
|
seq: wire_seq,
|
||||||
|
highest_seen,
|
||||||
|
remote: self.remote,
|
||||||
|
sent_at: Some(timed_out.sent_at),
|
||||||
|
received_at: now,
|
||||||
|
rtt: Some(rtt),
|
||||||
|
server_timing,
|
||||||
|
one_way,
|
||||||
|
received_stats,
|
||||||
|
bytes: packet_len,
|
||||||
|
packet_meta: meta.into(),
|
||||||
|
}];
|
||||||
|
if flags::has(reply.flags, flags::FLAG_CLOSE) {
|
||||||
|
self.close_from_peer(token, now, &mut events);
|
||||||
|
}
|
||||||
|
Ok(events)
|
||||||
|
} else if session
|
||||||
|
.highest_received_seq
|
||||||
|
.is_some_and(|h| sequence_is_before(wire_seq, h))
|
||||||
|
{
|
||||||
|
Ok(vec![ClientEvent::LateReply {
|
||||||
|
seq: wire_seq,
|
||||||
|
highest_seen: session.highest_received_seq.unwrap(),
|
||||||
|
remote: self.remote,
|
||||||
|
sent_at: None,
|
||||||
|
received_at: now,
|
||||||
|
rtt: None,
|
||||||
|
server_timing: build_server_timing(&reply.timestamps),
|
||||||
|
one_way: None,
|
||||||
|
received_stats: build_received_stats(&reply),
|
||||||
|
bytes: packet_len,
|
||||||
|
packet_meta: meta.into(),
|
||||||
|
}])
|
||||||
|
} else {
|
||||||
|
Ok(vec![ClientEvent::Warning {
|
||||||
|
kind: WarningKind::UntrackedReply,
|
||||||
|
message: format!(
|
||||||
|
"dropped reply with untracked seq {wire_seq} (no pending or completed entry)"
|
||||||
|
),
|
||||||
|
at: now,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_from_peer(&mut self, token: u64, now: ClientTimestamp, events: &mut Vec<ClientEvent>) {
|
||||||
|
self.phase = ClientPhase::Closed {
|
||||||
|
source: CloseSource::Peer,
|
||||||
|
};
|
||||||
|
if let Some(session) = self.session.as_mut() {
|
||||||
|
session.timed_out.clear();
|
||||||
|
}
|
||||||
|
self.session = None;
|
||||||
|
events.push(ClientEvent::SessionClosed {
|
||||||
|
remote: self.remote,
|
||||||
|
token,
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn recv_buffer_size(
|
||||||
|
has_hmac: bool,
|
||||||
|
negotiated: Option<&NegotiatedParams>,
|
||||||
|
) -> Result<usize, ClientError> {
|
||||||
|
Ok(match negotiated {
|
||||||
|
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.params)?
|
||||||
|
.saturating_add(1)
|
||||||
|
.max(MIN_RECV_BUFFER_SIZE),
|
||||||
|
None => MIN_RECV_BUFFER_SIZE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn params_from_config(config: &ClientConfig) -> Result<Params, ClientError> {
|
||||||
|
validate_protocol_config(config)?;
|
||||||
|
Ok(Params {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
duration_ns: match config.duration {
|
||||||
|
Some(duration) => config_duration_to_ns("duration", duration)?,
|
||||||
|
None => 0,
|
||||||
|
},
|
||||||
|
interval_ns: config_duration_to_ns("interval", config.interval)?,
|
||||||
|
length: i64::from(config.length),
|
||||||
|
received_stats: config.received_stats,
|
||||||
|
stamp_at: config.stamp_at,
|
||||||
|
clock: config.clock,
|
||||||
|
dscp: i64::from(config.dscp),
|
||||||
|
server_fill: config.server_fill.clone().map(|value| ServerFill { value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
|
||||||
|
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
|
||||||
|
if sequence_is_after(wire_seq, h) {
|
||||||
|
wire_seq
|
||||||
|
} else {
|
||||||
|
h
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_probe_deadline(
|
||||||
|
start: Instant,
|
||||||
|
interval_ns: u64,
|
||||||
|
packets_sent: u64,
|
||||||
|
) -> Result<Instant, ClientError> {
|
||||||
|
let offset_ns = interval_ns
|
||||||
|
.checked_mul(packets_sent)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?;
|
||||||
|
start
|
||||||
|
.checked_add(Duration::from_nanos(offset_ns))
|
||||||
|
.ok_or(ClientError::DurationOverflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sequence_is_after(candidate: u32, current: u32) -> bool {
|
||||||
|
candidate != current && candidate.wrapping_sub(current) < (1 << 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sequence_is_before(candidate: u32, current: u32) -> bool {
|
||||||
|
current != candidate && current.wrapping_sub(candidate) < (1 << 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||||
|
left.checked_duration_since(right)
|
||||||
|
.or_else(|| right.checked_duration_since(left))
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compute_rtt(
|
||||||
|
sent_at: &ClientTimestamp,
|
||||||
|
received_at: &ClientTimestamp,
|
||||||
|
ts: &TimestampFields,
|
||||||
|
) -> RttSample {
|
||||||
|
let raw = received_at
|
||||||
|
.mono
|
||||||
|
.checked_duration_since(sent_at.mono)
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
|
||||||
|
let server_processing = compute_server_processing(ts);
|
||||||
|
|
||||||
|
let adjusted = server_processing
|
||||||
|
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
|
||||||
|
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
|
||||||
|
|
||||||
|
RttSample {
|
||||||
|
raw,
|
||||||
|
adjusted,
|
||||||
|
effective,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_ns_i128(duration: Duration) -> i128 {
|
||||||
|
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_server_processing(ts: &TimestampFields) -> Option<Duration> {
|
||||||
|
if let (Some(recv_mono), Some(send_mono)) = (ts.recv_mono, ts.send_mono) {
|
||||||
|
let diff = send_mono.checked_sub(recv_mono)?;
|
||||||
|
return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
|
||||||
|
}
|
||||||
|
if let (Some(recv_wall), Some(send_wall)) = (ts.recv_wall, ts.send_wall) {
|
||||||
|
let diff = send_wall.checked_sub(recv_wall)?;
|
||||||
|
return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_server_timing(ts: &TimestampFields) -> Option<ServerTiming> {
|
||||||
|
if ts.recv_wall.is_none()
|
||||||
|
&& ts.recv_mono.is_none()
|
||||||
|
&& ts.send_wall.is_none()
|
||||||
|
&& ts.send_mono.is_none()
|
||||||
|
&& ts.midpoint_wall.is_none()
|
||||||
|
&& ts.midpoint_mono.is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ServerTiming {
|
||||||
|
receive_wall_ns: ts.recv_wall,
|
||||||
|
receive_mono_ns: ts.recv_mono,
|
||||||
|
send_wall_ns: ts.send_wall,
|
||||||
|
send_mono_ns: ts.send_mono,
|
||||||
|
midpoint_wall_ns: ts.midpoint_wall,
|
||||||
|
midpoint_mono_ns: ts.midpoint_mono,
|
||||||
|
processing: compute_server_processing(ts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compute_one_way(
|
||||||
|
sent_at: &ClientTimestamp,
|
||||||
|
received_at: &ClientTimestamp,
|
||||||
|
ts: &TimestampFields,
|
||||||
|
) -> Option<OneWayDelaySample> {
|
||||||
|
let server_recv_wall = ts.recv_wall.or(ts.midpoint_wall);
|
||||||
|
let server_send_wall = ts.send_wall.or(ts.midpoint_wall);
|
||||||
|
|
||||||
|
let client_send_ns = unix_epoch_ns_i64(sent_at.wall);
|
||||||
|
let client_recv_ns = unix_epoch_ns_i64(received_at.wall);
|
||||||
|
|
||||||
|
let c2s = server_recv_wall
|
||||||
|
.zip(client_send_ns)
|
||||||
|
.and_then(|(srv, cli)| srv.checked_sub(cli))
|
||||||
|
.map(|d| SignedDuration::from_nanos(i128::from(d)));
|
||||||
|
let s2c = client_recv_ns
|
||||||
|
.zip(server_send_wall)
|
||||||
|
.and_then(|(cli, srv)| cli.checked_sub(srv))
|
||||||
|
.map(|d| SignedDuration::from_nanos(i128::from(d)));
|
||||||
|
|
||||||
|
if c2s.is_none() && s2c.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(OneWayDelaySample {
|
||||||
|
client_to_server: c2s,
|
||||||
|
server_to_client: s2c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unix_epoch_ns_i64(time: SystemTime) -> Option<i64> {
|
||||||
|
time.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|duration| i64::try_from(duration.as_nanos()).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_received_stats(reply: &EchoReply) -> Option<ReceivedStatsSample> {
|
||||||
|
if reply.recv_count.is_none() && reply.recv_window.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ReceivedStatsSample {
|
||||||
|
count: reply.recv_count,
|
||||||
|
window: reply.recv_window,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
|
||||||
|
if config.duration == Some(Duration::ZERO) {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "duration must be greater than zero; use None for continuous mode".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.interval == Duration::ZERO {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "interval must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.dscp > MAX_DSCP_CODEPOINT {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.length > MAX_UDP_PAYLOAD_LENGTH {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(fill) = &config.server_fill {
|
||||||
|
let len = fill.len();
|
||||||
|
if len == 0 {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "server_fill must not be empty".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if len > MAX_SERVER_FILL_BYTES {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: format!("server_fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_duration_to_ns(field: &str, duration: Duration) -> Result<i64, ClientError> {
|
||||||
|
i64::try_from(duration.as_nanos()).map_err(|_| ClientError::InvalidConfig {
|
||||||
|
reason: format!("{field} is too large to encode as nanoseconds"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn negotiated_end_mono(start: Instant, duration_ns: i64) -> Result<Instant, ClientError> {
|
||||||
|
debug_assert!(duration_ns > 0);
|
||||||
|
let duration_ns = u64::try_from(duration_ns).expect("validated positive negotiated duration");
|
||||||
|
start
|
||||||
|
.checked_add(Duration::from_nanos(duration_ns))
|
||||||
|
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||||
|
reason: "duration is too large to schedule".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,9 +8,16 @@ use crate::{
|
|||||||
probe::{CompletedSet, PendingMap, TimedOutMap},
|
probe::{CompletedSet, PendingMap, TimedOutMap},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Protocol parameters accepted for a session.
|
||||||
|
///
|
||||||
|
/// `params` contains the server-returned values that the client will use for
|
||||||
|
/// echo packets. `restrictions` records accepted differences from the request
|
||||||
|
/// when loose negotiation is enabled.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct NegotiatedParams {
|
pub struct NegotiatedParams {
|
||||||
|
/// Server-returned protocol parameters used for the session.
|
||||||
pub params: Params,
|
pub params: Params,
|
||||||
|
/// Accepted server restrictions or parameter changes.
|
||||||
pub restrictions: Vec<NegotiationRestriction>,
|
pub restrictions: Vec<NegotiationRestriction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +64,7 @@ pub enum NegotiationRestriction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NegotiationRestriction {
|
impl NegotiationRestriction {
|
||||||
|
/// Return a human-readable description of this negotiated restriction.
|
||||||
pub fn message(&self) -> String {
|
pub fn message(&self) -> String {
|
||||||
self.to_string()
|
self.to_string()
|
||||||
}
|
}
|
||||||
@@ -153,7 +161,13 @@ pub(crate) enum ClientPhase {
|
|||||||
Connected,
|
Connected,
|
||||||
Open { token: u64 },
|
Open { token: u64 },
|
||||||
NoTestCompleted,
|
NoTestCompleted,
|
||||||
Closed,
|
Closed { source: CloseSource },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CloseSource {
|
||||||
|
Local,
|
||||||
|
Peer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -99,6 +99,44 @@ pub(crate) fn connect_udp_socket(
|
|||||||
Ok(socket)
|
Ok(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bind_unconnected_udp_socket(
|
||||||
|
config: &SocketConfig,
|
||||||
|
family_remote: SocketAddr,
|
||||||
|
) -> Result<UdpSocket, ClientError> {
|
||||||
|
let domain = if family_remote.is_ipv4() {
|
||||||
|
Domain::IPV4
|
||||||
|
} else {
|
||||||
|
Domain::IPV6
|
||||||
|
};
|
||||||
|
let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
|
||||||
|
|
||||||
|
if config.ipv6_only && family_remote.is_ipv6() {
|
||||||
|
socket.set_only_v6(true)?;
|
||||||
|
}
|
||||||
|
let bind_addr = config.bind_addr.unwrap_or_else(|| {
|
||||||
|
if family_remote.is_ipv4() {
|
||||||
|
SocketAddr::from(([0, 0, 0, 0], 0))
|
||||||
|
} else {
|
||||||
|
SocketAddr::from(([0_u16; 8], 0))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.bind(&bind_addr.into())?;
|
||||||
|
|
||||||
|
let socket: UdpSocket = socket.into();
|
||||||
|
configure_receive_metadata(&socket, family_remote).map_err(|source| {
|
||||||
|
ClientError::SocketOption {
|
||||||
|
operation: "enable receive metadata",
|
||||||
|
remote: family_remote,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if let Some(ttl) = config.ttl {
|
||||||
|
apply_ttl_to_socket(&socket, family_remote, ttl)?;
|
||||||
|
}
|
||||||
|
socket.set_read_timeout(config.recv_timeout)?;
|
||||||
|
Ok(socket)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
use std::time::{Instant, SystemTime};
|
use std::time::{Instant, SystemTime};
|
||||||
|
|
||||||
|
/// Client-side timestamp captured in both wall-clock and monotonic time.
|
||||||
|
///
|
||||||
|
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
|
||||||
|
/// calculations. Monotonic time is used for elapsed client-side timing such as
|
||||||
|
/// RTT, scheduling, and timeouts.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ClientTimestamp {
|
pub struct ClientTimestamp {
|
||||||
|
/// System wall-clock time.
|
||||||
pub wall: SystemTime,
|
pub wall: SystemTime,
|
||||||
|
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
|
||||||
pub mono: Instant,
|
pub mono: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientTimestamp {
|
impl ClientTimestamp {
|
||||||
|
/// Capture the current wall-clock and monotonic timestamps.
|
||||||
pub fn now() -> Self {
|
pub fn now() -> Self {
|
||||||
Self {
|
Self {
|
||||||
wall: SystemTime::now(),
|
wall: SystemTime::now(),
|
||||||
|
|||||||
@@ -122,47 +122,27 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_client_key_against_hmac_required_server_times_out() {
|
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
|
||||||
let key = b"compat-secret".to_vec();
|
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
|
||||||
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250));
|
let server_key = b"compat-secret".to_vec();
|
||||||
let mut config = config_for_params(server.addr, &default_params());
|
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
let mut config = config_for_params(server.addr, &default_params());
|
||||||
config.hmac_key = None;
|
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||||
|
config.hmac_key = hmac_key;
|
||||||
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
let mut client = Client::connect(config).unwrap();
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||||
|
|
||||||
let observations = server.observations(1);
|
let observations = server.observations(1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
observations.as_slice(),
|
observations.as_slice(),
|
||||||
[ServerObservation::RejectedHmac {
|
[ServerObservation::RejectedHmac {
|
||||||
hmac: false,
|
hmac,
|
||||||
bad_hmac: true
|
bad_hmac: true
|
||||||
}]
|
}] if *hmac == expected_hmac_flag
|
||||||
));
|
));
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wrong_client_key_against_hmac_required_server_times_out() {
|
|
||||||
let server_key = b"compat-secret".to_vec();
|
|
||||||
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
|
|
||||||
let mut config = config_for_params(server.addr, &default_params());
|
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
|
||||||
config.hmac_key = Some(b"wrong-secret".to_vec());
|
|
||||||
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
|
|
||||||
let observations = server.observations(1);
|
|
||||||
assert!(matches!(
|
|
||||||
observations.as_slice(),
|
|
||||||
[ServerObservation::RejectedHmac {
|
|
||||||
hmac: true,
|
|
||||||
bad_hmac: true
|
|
||||||
}]
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -311,25 +291,15 @@ fn backend_hmac_correct_key_succeeds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_hmac_wrong_key_fails() {
|
fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
|
||||||
let server_key = b"compat-secret".to_vec();
|
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
|
||||||
let peer = BackendPeer::start_hmac_required(server_key);
|
let server_key = b"compat-secret".to_vec();
|
||||||
let mut config = config_for_params(peer.addr(), &default_params());
|
let peer = BackendPeer::start_hmac_required(server_key);
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
let mut config = config_for_params(peer.addr(), &default_params());
|
||||||
config.hmac_key = Some(b"wrong-secret".to_vec());
|
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||||
|
config.hmac_key = hmac_key;
|
||||||
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
let mut client = Client::connect(config).unwrap();
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_hmac_missing_key_fails() {
|
|
||||||
let server_key = b"compat-secret".to_vec();
|
|
||||||
let peer = BackendPeer::start_hmac_required(server_key);
|
|
||||||
let mut config = config_for_params(peer.addr(), &default_params());
|
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
|
||||||
config.hmac_key = None;
|
|
||||||
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
mod protocol_options {
|
||||||
|
pub mod backend_smoke;
|
||||||
|
}
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
|
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
|
||||||
RttSample, ServerTiming,
|
RttSample, ServerTiming, SignedDuration,
|
||||||
};
|
};
|
||||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||||
|
|
||||||
use support::{
|
use support::{
|
||||||
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
|
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
|
||||||
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
|
server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
|
||||||
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
|
RECV_COUNT, RECV_WINDOW, TOKEN,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::support::test_echo_packet_len;
|
use crate::support::test_echo_packet_len;
|
||||||
@@ -66,7 +69,10 @@ fn timestamp_modes_drive_reply_event_timing_fields() {
|
|||||||
assert!(reply.server_timing.is_none());
|
assert!(reply.server_timing.is_none());
|
||||||
assert!(reply.one_way.is_none());
|
assert!(reply.one_way.is_none());
|
||||||
assert!(reply.rtt.adjusted.is_none());
|
assert!(reply.rtt.adjusted.is_none());
|
||||||
assert_eq!(reply.rtt.effective, reply.rtt.raw);
|
assert_eq!(
|
||||||
|
reply.rtt.effective,
|
||||||
|
SignedDuration::from_duration(reply.rtt.raw)
|
||||||
|
);
|
||||||
assert!(reply.rtt.raw > Duration::ZERO);
|
assert!(reply.rtt.raw > Duration::ZERO);
|
||||||
}
|
}
|
||||||
StampAt::Send => {
|
StampAt::Send => {
|
||||||
@@ -346,7 +352,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
|
|||||||
assert_eq!(processing, Duration::from_nanos(1));
|
assert_eq!(processing, Duration::from_nanos(1));
|
||||||
let adjusted = reply.rtt.adjusted.unwrap();
|
let adjusted = reply.rtt.adjusted.unwrap();
|
||||||
assert_eq!(reply.rtt.effective, adjusted);
|
assert_eq!(reply.rtt.effective, adjusted);
|
||||||
assert!(adjusted < reply.rtt.raw);
|
assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_one_way_presence(
|
fn assert_one_way_presence(
|
||||||
@@ -364,46 +370,6 @@ fn assert_one_way_presence(
|
|||||||
assert_eq!(sample.server_to_client.is_some(), server_to_client);
|
assert_eq!(sample.server_to_client.is_some(), server_to_client);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_basic_open_echo_close() {
|
|
||||||
let params = default_params();
|
|
||||||
let peer = BackendPeer::start_open_echo(params.clone(), None);
|
|
||||||
let mut client = Client::connect(config_for_params(peer.addr(), ¶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]
|
#[test]
|
||||||
fn dscp_configured_open_close_smoke() {
|
fn dscp_configured_open_close_smoke() {
|
||||||
let mut params = default_params();
|
let mut params = default_params();
|
||||||
@@ -416,117 +382,3 @@ fn dscp_configured_open_close_smoke() {
|
|||||||
client.close().unwrap();
|
client.close().unwrap();
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_received_stats_smoke() {
|
|
||||||
for mode in [
|
|
||||||
ReceivedStats::None,
|
|
||||||
ReceivedStats::Count,
|
|
||||||
ReceivedStats::Window,
|
|
||||||
ReceivedStats::Both,
|
|
||||||
] {
|
|
||||||
let params = params_for_modes(mode, StampAt::None, Clock::Both);
|
|
||||||
let peer = BackendPeer::start_open_echo(params.clone(), None);
|
|
||||||
let mut client = Client::connect(config_for_params(peer.addr(), ¶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;
|
mod support;
|
||||||
|
|
||||||
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, NegotiationPolicy};
|
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
|
||||||
use irtt_proto::{Params, TimestampFields};
|
use irtt_proto::{Params, TimestampFields};
|
||||||
|
|
||||||
use support::{
|
use support::{
|
||||||
@@ -32,63 +32,34 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
|
|||||||
// ─── Config mapping ───
|
// ─── Config mapping ───
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_none_produces_no_fill_in_open_params() {
|
fn server_fill_none_short_and_max_map_into_open_params() {
|
||||||
let params = default_params();
|
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
|
||||||
let run = run_one_probe(params, TimestampFields::default());
|
let mut params = default_params();
|
||||||
assert!(open_observation_params(&run).server_fill.is_none());
|
params.server_fill = fill.and_then(server_fill);
|
||||||
|
|
||||||
|
let run = run_one_probe(params, TimestampFields::default());
|
||||||
|
assert_eq!(
|
||||||
|
open_observation_params(&run)
|
||||||
|
.server_fill
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.value.as_str()),
|
||||||
|
fill
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_short_value_maps_into_open_params() {
|
fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
|
||||||
let mut params = default_params();
|
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
|
||||||
params.server_fill = server_fill("abc");
|
let config = ClientConfig {
|
||||||
let run = run_one_probe(params, TimestampFields::default());
|
server_fill: Some(fill.to_owned()),
|
||||||
assert_eq!(
|
..ClientConfig::default()
|
||||||
open_observation_params(&run)
|
};
|
||||||
.server_fill
|
assert!(matches!(
|
||||||
.as_ref()
|
Client::connect(config),
|
||||||
.map(|f| f.value.as_str()),
|
Err(ClientError::InvalidConfig { .. })
|
||||||
Some("abc")
|
));
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_32_byte_value_maps_into_open_params() {
|
|
||||||
let mut params = default_params();
|
|
||||||
let fill = "0123456789abcdef0123456789abcdef";
|
|
||||||
params.server_fill = server_fill(fill);
|
|
||||||
let run = run_one_probe(params, TimestampFields::default());
|
|
||||||
assert_eq!(
|
|
||||||
open_observation_params(&run)
|
|
||||||
.server_fill
|
|
||||||
.as_ref()
|
|
||||||
.map(|f| f.value.as_str()),
|
|
||||||
Some(fill)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_over_32_bytes_rejected_at_config_boundary() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_empty_rejected_at_config_boundary() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
server_fill: Some("".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Open negotiation ───
|
// ─── Open negotiation ───
|
||||||
@@ -102,53 +73,26 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_strict_rejects_removed_fill() {
|
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
|
||||||
let mut requested = default_params();
|
for (requested_fill, returned_fill) in [
|
||||||
requested.server_fill = server_fill("rand");
|
(Some("rand"), None),
|
||||||
|
(Some("abc"), Some("xyz")),
|
||||||
|
(None, Some("unexpected")),
|
||||||
|
] {
|
||||||
|
let mut requested = default_params();
|
||||||
|
requested.server_fill = requested_fill.and_then(server_fill);
|
||||||
|
|
||||||
let mut returned = requested.clone();
|
let mut returned = requested.clone();
|
||||||
returned.server_fill = None;
|
returned.server_fill = returned_fill.and_then(server_fill);
|
||||||
|
|
||||||
let server = start_open_server(returned, None);
|
let server = start_open_server(returned, None);
|
||||||
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
|
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.open(),
|
client.open(),
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
Err(ClientError::NegotiationRejected { .. })
|
||||||
));
|
));
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_strict_rejects_different_fill_value() {
|
|
||||||
let mut requested = default_params();
|
|
||||||
requested.server_fill = server_fill("abc");
|
|
||||||
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.server_fill = server_fill("xyz");
|
|
||||||
|
|
||||||
let server = start_open_server(returned, None);
|
|
||||||
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_strict_rejects_unexpected_fill_from_server() {
|
|
||||||
let requested = default_params();
|
|
||||||
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.server_fill = server_fill("unexpected");
|
|
||||||
|
|
||||||
let server = start_open_server(returned, None);
|
|
||||||
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -176,14 +120,6 @@ fn server_fill_loose_allows_server_to_remove_fill() {
|
|||||||
|
|
||||||
// ─── Echo behavior ───
|
// ─── Echo behavior ───
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_echo_reply_decoded_with_fill_configured() {
|
|
||||||
let mut params = default_params();
|
|
||||||
params.server_fill = server_fill("rand");
|
|
||||||
let run = run_one_probe(params, TimestampFields::default());
|
|
||||||
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// server_fill is a negotiation/config parameter only; it does not affect
|
// server_fill is a negotiation/config parameter only; it does not affect
|
||||||
// client-observable reply payload layout. The client sends server_fill in
|
// client-observable reply payload layout. The client sends server_fill in
|
||||||
// the open request and expects the server to use it for reply payload
|
// the open request and expects the server to use it for reply payload
|
||||||
|
|||||||
@@ -275,61 +275,3 @@ macro_rules! debug_eprintln {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use debug_eprintln;
|
use debug_eprintln;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_failure_details_include_bind_command_status_and_output() {
|
|
||||||
let status = Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg("exit 7")
|
|
||||||
.status()
|
|
||||||
.expect("shell exits");
|
|
||||||
let failure = StartupFailure {
|
|
||||||
bind: "127.0.0.1:12345".to_owned(),
|
|
||||||
command: "\"irtt\" \"server\"".to_owned(),
|
|
||||||
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
|
|
||||||
exit_status: Some(status),
|
|
||||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let details = failure.details();
|
|
||||||
|
|
||||||
assert!(details.contains("bind: 127.0.0.1:12345"));
|
|
||||||
assert!(details.contains("command: \"irtt\" \"server\""));
|
|
||||||
assert!(details.contains("exit status:"));
|
|
||||||
assert!(details.contains("address already in use"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
|
|
||||||
let failures = vec![
|
|
||||||
StartupFailure {
|
|
||||||
bind: "127.0.0.1:11111".to_owned(),
|
|
||||||
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
|
|
||||||
output: "stdout: first".to_owned(),
|
|
||||||
exit_status: None,
|
|
||||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
|
||||||
},
|
|
||||||
StartupFailure {
|
|
||||||
bind: "127.0.0.1:22222".to_owned(),
|
|
||||||
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
|
|
||||||
output: "stderr: second".to_owned(),
|
|
||||||
exit_status: None,
|
|
||||||
error: Some("readiness timed out or server exited before ready".to_owned()),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let message = format_startup_failures("irtt", &failures);
|
|
||||||
|
|
||||||
assert!(message.contains("after 2 attempt(s)"));
|
|
||||||
assert!(message.contains("attempt 1/2"));
|
|
||||||
assert!(message.contains("attempt 2/2"));
|
|
||||||
assert!(message.contains("127.0.0.1:11111"));
|
|
||||||
assert!(message.contains("127.0.0.1:22222"));
|
|
||||||
assert!(message.contains("stdout: first"));
|
|
||||||
assert!(message.contains("stderr: second"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-proto"
|
name = "irtt-proto"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "IRTT protocol encoding and decoding primitives"
|
description = "IRTT protocol encoding and decoding primitives"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -31,20 +31,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{hmac, FLAG_HMAC};
|
use crate::{hmac, FLAG_HMAC};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_request_sizes() {
|
|
||||||
let request = CloseRequest {
|
|
||||||
token: 0x7896_b6ab_8771_5213,
|
|
||||||
};
|
|
||||||
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
|
|
||||||
assert_eq!(
|
|
||||||
encode_close_request(&request, Some(b"testkey"))
|
|
||||||
.unwrap()
|
|
||||||
.len(),
|
|
||||||
28
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hmac_close_request_places_token_after_hmac() {
|
fn hmac_close_request_places_token_after_hmac() {
|
||||||
let packet = encode_close_request(
|
let packet = encode_close_request(
|
||||||
|
|||||||
@@ -98,16 +98,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_hmac_does_not_mutate_input() {
|
|
||||||
let packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
|
||||||
let original = packet.clone();
|
|
||||||
|
|
||||||
let _digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(packet, original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compute_hmac_matches_copy_and_zero_semantics() {
|
fn compute_hmac_matches_copy_and_zero_semantics() {
|
||||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
||||||
@@ -119,42 +109,6 @@ mod tests {
|
|||||||
assert_eq!(digest, reference);
|
assert_eq!(digest, reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_hmac_in_place_does_not_set_hmac_flag() {
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
|
||||||
packet.push(FLAG_OPEN);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(packet[3], FLAG_OPEN);
|
|
||||||
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
|
|
||||||
.iter()
|
|
||||||
.any(|byte| *byte != 0));
|
|
||||||
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_hmac_in_place_only_writes_hmac_field() {
|
|
||||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
|
|
||||||
let original = packet.clone();
|
|
||||||
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
|
|
||||||
assert_ne!(
|
|
||||||
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
|
|
||||||
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&packet[hmac_offset() + HMAC_SIZE..],
|
|
||||||
&original[hmac_offset() + HMAC_SIZE..]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_hmac_rejects_modified_authenticated_byte() {
|
fn verify_hmac_rejects_modified_authenticated_byte() {
|
||||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verified_layout_lengths() {
|
fn verified_layout_lengths() {
|
||||||
|
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
||||||
|
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
||||||
|
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
||||||
|
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
echo_header_len(
|
echo_header_len(
|
||||||
false,
|
false,
|
||||||
@@ -199,16 +205,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_and_close_layout_lengths_account_for_hmac() {
|
|
||||||
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
|
||||||
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
|
||||||
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
|
||||||
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
||||||
for stats in [
|
for stats in [
|
||||||
@@ -280,16 +276,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_timestamp_layout_ignores_clock() {
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(false, ¶ms(ReceivedStats::None, StampAt::None, clock)),
|
|
||||||
16
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negotiated_length_never_smaller_than_header() {
|
fn negotiated_length_never_smaller_than_header() {
|
||||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
//! Low-level wire encode/decode support for the IRTT protocol.
|
||||||
|
//!
|
||||||
|
//! `irtt-proto` intentionally mirrors protocol fields closely: request/reply
|
||||||
|
//! structs expose wire-oriented values, flags, counters, timestamps, and
|
||||||
|
//! parameter enums rather than higher-level policy decisions. Higher-level
|
||||||
|
//! crates such as `irtt-client` are responsible for session behavior and
|
||||||
|
//! user-facing validation.
|
||||||
|
//!
|
||||||
|
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
|
||||||
|
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
|
||||||
|
//! `server_fill` values. Direct construction followed by [`Params::encode`]
|
||||||
|
//! performs no additional validation, so callers that build `Params` manually
|
||||||
|
//! are responsible for validating those values before sending them.
|
||||||
|
//!
|
||||||
|
//! The crate also provides packet layout calculation, open/echo/close packet
|
||||||
|
//! encoding and decoding, and optional HMAC placement, computation, and
|
||||||
|
//! verification helpers.
|
||||||
|
//!
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod close;
|
pub mod close;
|
||||||
@@ -17,7 +35,7 @@ pub use flags::*;
|
|||||||
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
|
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
|
||||||
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
|
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
|
||||||
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
|
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
|
||||||
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
|
||||||
|
|
||||||
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
|
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
|
||||||
pub const PROTOCOL_VERSION: i64 = 1;
|
pub const PROTOCOL_VERSION: i64 = 1;
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
|
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
|
||||||
|
|
||||||
|
/// Protocol compatibility bound for an encoded `server_fill` parameter.
|
||||||
|
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||||
|
|
||||||
|
/// Low-level wire representation of IRTT open parameters.
|
||||||
|
///
|
||||||
|
/// `Params` mirrors the protocol fields closely and can be constructed
|
||||||
|
/// directly by callers. Direct construction can therefore produce values that
|
||||||
|
/// should not be sent on the wire, such as an oversized `server_fill` value.
|
||||||
|
/// Higher-level callers should validate user or configuration input before
|
||||||
|
/// encoding. The normal `irtt-client` configuration path enforces
|
||||||
|
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
pub protocol_version: i64,
|
pub protocol_version: i64,
|
||||||
@@ -21,6 +32,10 @@ impl Params {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes these parameters without performing additional validation.
|
||||||
|
///
|
||||||
|
/// Callers that construct `Params` directly are responsible for validating
|
||||||
|
/// user or configuration input before encoding.
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
push_int(1, self.protocol_version, &mut out);
|
push_int(1, self.protocol_version, &mut out);
|
||||||
@@ -39,6 +54,10 @@ impl Params {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes parameters and rejects malformed or incompatible incoming values.
|
||||||
|
///
|
||||||
|
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
|
||||||
|
/// values longer than [`MAX_SERVER_FILL_BYTES`].
|
||||||
pub fn decode(input: &[u8]) -> Result<Self> {
|
pub fn decode(input: &[u8]) -> Result<Self> {
|
||||||
let mut params = Self::default();
|
let mut params = Self::default();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
@@ -59,6 +78,12 @@ impl Params {
|
|||||||
pos += used;
|
pos += used;
|
||||||
let len = usize::try_from(len)
|
let len = usize::try_from(len)
|
||||||
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
|
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
|
||||||
|
if len > MAX_SERVER_FILL_BYTES {
|
||||||
|
return Err(ProtoError::ParameterLengthTooLarge {
|
||||||
|
tag,
|
||||||
|
length: len as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
if input.len().saturating_sub(pos) < len {
|
if input.len().saturating_sub(pos) < len {
|
||||||
return Err(ProtoError::MalformedParams);
|
return Err(ProtoError::MalformedParams);
|
||||||
}
|
}
|
||||||
@@ -206,6 +231,14 @@ mod tests {
|
|||||||
assert_eq!(Params::decode(¶ms.encode()), Ok(params));
|
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]
|
#[test]
|
||||||
fn params_round_trip() {
|
fn params_round_trip() {
|
||||||
let params = Params {
|
let params = Params {
|
||||||
@@ -225,7 +258,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_round_trip_received_stats_modes() {
|
fn params_round_trip_negotiated_option_modes() {
|
||||||
for received_stats in [
|
for received_stats in [
|
||||||
ReceivedStats::None,
|
ReceivedStats::None,
|
||||||
ReceivedStats::Count,
|
ReceivedStats::Count,
|
||||||
@@ -238,10 +271,7 @@ mod tests {
|
|||||||
..Params::default()
|
..Params::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_timestamp_modes() {
|
|
||||||
for stamp_at in [
|
for stamp_at in [
|
||||||
StampAt::None,
|
StampAt::None,
|
||||||
StampAt::Send,
|
StampAt::Send,
|
||||||
@@ -256,10 +286,7 @@ mod tests {
|
|||||||
..Params::default()
|
..Params::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_clock_modes() {
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
||||||
assert_round_trip(Params {
|
assert_round_trip(Params {
|
||||||
protocol_version: 1,
|
protocol_version: 1,
|
||||||
@@ -323,6 +350,29 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_fill_decode_accepts_max_length() {
|
||||||
|
let value = b"0123456789abcdef0123456789abcdef";
|
||||||
|
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params.server_fill,
|
||||||
|
Some(ServerFill {
|
||||||
|
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_fill_decode_rejects_oversized_length() {
|
||||||
|
let value = b"0123456789abcdef0123456789abcdefx";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Params::decode(&encode_server_fill_value(value)),
|
||||||
|
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
|
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
|
||||||
let params = Params {
|
let params = Params {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "Statistics aggregation for irtt-client events"
|
description = "Statistics aggregation for irtt-client events"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ impl CoreStats {
|
|||||||
StatsEvent::UniqueReply {
|
StatsEvent::UniqueReply {
|
||||||
is_late, sample, ..
|
is_late, sample, ..
|
||||||
} => self.apply_unique_reply(is_late, *sample),
|
} => self.apply_unique_reply(is_late, *sample),
|
||||||
StatsEvent::DuplicateReply { .. } => {
|
StatsEvent::DuplicateReply { bytes, .. } => {
|
||||||
self.apply_duplicate_reply();
|
self.apply_duplicate_reply(bytes);
|
||||||
EventStatsUpdate::default()
|
EventStatsUpdate::default()
|
||||||
}
|
}
|
||||||
StatsEvent::Loss { .. } => {
|
StatsEvent::Loss { .. } => {
|
||||||
@@ -81,8 +81,8 @@ impl CoreStats {
|
|||||||
self.apply_warning();
|
self.apply_warning();
|
||||||
EventStatsUpdate::default()
|
EventStatsUpdate::default()
|
||||||
}
|
}
|
||||||
StatsEvent::UntrackedLate { .. } => {
|
StatsEvent::UntrackedLate { bytes, .. } => {
|
||||||
self.apply_untracked_late();
|
self.apply_untracked_late(bytes);
|
||||||
EventStatsUpdate::default()
|
EventStatsUpdate::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,12 @@ impl CoreStats {
|
|||||||
.saturating_add(sample.bytes as u64);
|
.saturating_add(sample.bytes as u64);
|
||||||
|
|
||||||
if let Some(count) = sample.received_count {
|
if let Some(count) = sample.received_count {
|
||||||
self.packets.server_packets_received = Some(u64::from(count));
|
let count = u64::from(count);
|
||||||
|
self.packets.server_packets_received = Some(
|
||||||
|
self.packets
|
||||||
|
.server_packets_received
|
||||||
|
.map_or(count, |current| current.max(count)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(window) = sample.received_window {
|
if let Some(window) = sample.received_window {
|
||||||
self.packets.server_received_window = Some(window);
|
self.packets.server_received_window = Some(window);
|
||||||
@@ -181,10 +186,11 @@ impl CoreStats {
|
|||||||
updates
|
updates
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_duplicate_reply(&mut self) {
|
fn apply_duplicate_reply(&mut self, bytes: usize) {
|
||||||
self.events.duplicate_replies += 1;
|
self.events.duplicate_replies += 1;
|
||||||
self.packets.packets_received += 1;
|
self.packets.packets_received += 1;
|
||||||
self.packets.duplicates += 1;
|
self.packets.duplicates += 1;
|
||||||
|
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_loss(&mut self) {
|
fn apply_loss(&mut self) {
|
||||||
@@ -195,8 +201,11 @@ impl CoreStats {
|
|||||||
self.events.warning_events += 1;
|
self.events.warning_events += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_untracked_late(&mut self) {
|
fn apply_untracked_late(&mut self, bytes: usize) {
|
||||||
self.events.untracked_late_replies += 1;
|
self.events.untracked_late_replies += 1;
|
||||||
|
self.packets.packets_received += 1;
|
||||||
|
self.packets.late_packets += 1;
|
||||||
|
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||||
|
|||||||
@@ -48,10 +48,8 @@ impl IpdvTracker {
|
|||||||
if let Some(pair) = self.try_pair(seq) {
|
if let Some(pair) = self.try_pair(seq) {
|
||||||
pairs.push(pair);
|
pairs.push(pair);
|
||||||
}
|
}
|
||||||
if let Some(next) = seq.checked_add(1) {
|
if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
|
||||||
if let Some(pair) = self.try_pair(next) {
|
pairs.push(pair);
|
||||||
pairs.push(pair);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pairs
|
pairs
|
||||||
}
|
}
|
||||||
@@ -66,15 +64,13 @@ impl IpdvTracker {
|
|||||||
};
|
};
|
||||||
if self.samples.remove(&seq).is_some() {
|
if self.samples.remove(&seq).is_some() {
|
||||||
self.completed_pairs.remove(&seq);
|
self.completed_pairs.remove(&seq);
|
||||||
if let Some(next) = seq.checked_add(1) {
|
self.completed_pairs.remove(&seq.wrapping_add(1));
|
||||||
self.completed_pairs.remove(&next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> {
|
fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> {
|
||||||
let previous_seq = current_seq.checked_sub(1)?;
|
let previous_seq = current_seq.wrapping_sub(1);
|
||||||
|
|
||||||
if !self.completed_pairs.insert(current_seq) {
|
if !self.completed_pairs.insert(current_seq) {
|
||||||
return None;
|
return None;
|
||||||
@@ -237,6 +233,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipdv_tracker_completes_wrapped_adjacent_pair() {
|
||||||
|
let mut tracker = IpdvTracker::new(None);
|
||||||
|
assert!(tracker.insert(ipdv_sample(u32::MAX, 10)).is_empty());
|
||||||
|
|
||||||
|
let pairs = tracker.insert(ipdv_sample(0, 14));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pairs,
|
||||||
|
vec![CompletedIpdvPair {
|
||||||
|
previous_seq: u32::MAX,
|
||||||
|
current_seq: 0,
|
||||||
|
rtt_ipdv_ns: 4,
|
||||||
|
send_ipdv_ns: None,
|
||||||
|
receive_ipdv_ns: None,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipdv_tracker_wrap_gap_does_not_complete_pair() {
|
||||||
|
let mut tracker = IpdvTracker::new(None);
|
||||||
|
assert!(tracker.insert(ipdv_sample(u32::MAX - 1, 10)).is_empty());
|
||||||
|
|
||||||
|
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipdv_tracker_late_wrapped_previous_completes_pair() {
|
||||||
|
let mut tracker = IpdvTracker::new(None);
|
||||||
|
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
|
||||||
|
|
||||||
|
let pairs = tracker.insert(ipdv_sample(u32::MAX, 10));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pairs,
|
||||||
|
vec![CompletedIpdvPair {
|
||||||
|
previous_seq: u32::MAX,
|
||||||
|
current_seq: 0,
|
||||||
|
rtt_ipdv_ns: 4,
|
||||||
|
send_ipdv_ns: None,
|
||||||
|
receive_ipdv_ns: None,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
|
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
|
||||||
let mut tracker = IpdvTracker::new(None);
|
let mut tracker = IpdvTracker::new(None);
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ pub struct EventCounts {
|
|||||||
/// Warning events processed.
|
/// Warning events processed.
|
||||||
pub warning_events: u64,
|
pub warning_events: u64,
|
||||||
/// Late replies that could not be matched to retained in-flight state.
|
/// Late replies that could not be matched to retained in-flight state.
|
||||||
|
///
|
||||||
|
/// These replies are observed on the socket, but cannot contribute RTT,
|
||||||
|
/// one-way delay, server-processing, or IPDV samples.
|
||||||
pub untracked_late_replies: u64,
|
pub untracked_late_replies: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,19 +209,26 @@ pub struct EventCounts {
|
|||||||
pub struct PacketCounts {
|
pub struct PacketCounts {
|
||||||
/// Probe packets sent by the local client.
|
/// Probe packets sent by the local client.
|
||||||
pub packets_sent: u64,
|
pub packets_sent: u64,
|
||||||
/// Reply packets received by the local client, including duplicates and late packets.
|
/// Reply packets observed by the local client.
|
||||||
|
///
|
||||||
|
/// This includes unique measurable replies, duplicate replies, and late
|
||||||
|
/// replies that cannot be matched to retained in-flight state.
|
||||||
pub packets_received: u64,
|
pub packets_received: u64,
|
||||||
/// Unique replies received by the local client.
|
/// Unique replies that can contribute timing measurements.
|
||||||
pub unique_replies: u64,
|
pub unique_replies: u64,
|
||||||
/// Duplicate replies received by the local client.
|
/// Duplicate replies received by the local client.
|
||||||
pub duplicates: u64,
|
pub duplicates: u64,
|
||||||
/// Late reply packets received by the local client.
|
/// Late reply packets received by the local client, including untracked-late replies.
|
||||||
pub late_packets: u64,
|
pub late_packets: u64,
|
||||||
/// Probe bytes sent by the local client.
|
/// Probe bytes sent by the local client.
|
||||||
pub bytes_sent: u64,
|
pub bytes_sent: u64,
|
||||||
/// Reply bytes received by the local client.
|
/// Reply bytes observed by the local client.
|
||||||
|
///
|
||||||
|
/// This includes bytes from unique measurable replies, duplicate replies,
|
||||||
|
/// and untracked-late replies. Per-category byte counts are not currently
|
||||||
|
/// exposed separately.
|
||||||
pub bytes_received: u64,
|
pub bytes_received: u64,
|
||||||
/// Server-reported packets received, when available.
|
/// Highest cumulative server-reported packets received, when available.
|
||||||
pub server_packets_received: Option<u64>,
|
pub server_packets_received: Option<u64>,
|
||||||
/// Server-reported receive window, when available.
|
/// Server-reported receive window, when available.
|
||||||
pub server_received_window: Option<u64>,
|
pub server_received_window: Option<u64>,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub(crate) enum StatsEvent {
|
|||||||
},
|
},
|
||||||
DuplicateReply {
|
DuplicateReply {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
|
bytes: usize,
|
||||||
},
|
},
|
||||||
Loss {
|
Loss {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
@@ -31,6 +32,7 @@ pub(crate) enum StatsEvent {
|
|||||||
},
|
},
|
||||||
UntrackedLate {
|
UntrackedLate {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
|
bytes: usize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +98,17 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
|
|||||||
*bytes,
|
*bytes,
|
||||||
)),
|
)),
|
||||||
}),
|
}),
|
||||||
ClientEvent::LateReply { received_at, .. } => Some(StatsEvent::UntrackedLate {
|
ClientEvent::LateReply {
|
||||||
|
received_at, bytes, ..
|
||||||
|
} => Some(StatsEvent::UntrackedLate {
|
||||||
at: received_at.mono,
|
at: received_at.mono,
|
||||||
|
bytes: *bytes,
|
||||||
}),
|
}),
|
||||||
ClientEvent::DuplicateReply { received_at, .. } => Some(StatsEvent::DuplicateReply {
|
ClientEvent::DuplicateReply {
|
||||||
|
received_at, bytes, ..
|
||||||
|
} => Some(StatsEvent::DuplicateReply {
|
||||||
at: received_at.mono,
|
at: received_at.mono,
|
||||||
|
bytes: *bytes,
|
||||||
}),
|
}),
|
||||||
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
|
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
|
||||||
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
|
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
|
||||||
@@ -115,10 +123,10 @@ impl StatsEvent {
|
|||||||
match self {
|
match self {
|
||||||
Self::Sent { at, .. }
|
Self::Sent { at, .. }
|
||||||
| Self::UniqueReply { at, .. }
|
| Self::UniqueReply { at, .. }
|
||||||
| Self::DuplicateReply { at }
|
| Self::DuplicateReply { at, .. }
|
||||||
| Self::Loss { at }
|
| Self::Loss { at }
|
||||||
| Self::Warning { at }
|
| Self::Warning { at }
|
||||||
| Self::UntrackedLate { at } => *at,
|
| Self::UntrackedLate { at, .. } => *at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,17 +156,17 @@ impl ReplySample {
|
|||||||
received_stats: Option<ReceivedStatsSample>,
|
received_stats: Option<ReceivedStatsSample>,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let rtt_primary_ns = signed_duration_ns(rtt.effective_signed);
|
let rtt_primary_ns = signed_duration_ns(rtt.effective);
|
||||||
Self {
|
Self {
|
||||||
bytes,
|
bytes,
|
||||||
rtt_raw_ns: duration_ns_i128(rtt.raw),
|
rtt_raw_ns: duration_ns_i128(rtt.raw),
|
||||||
rtt_adjusted_ns: rtt.adjusted_signed.map(signed_duration_ns),
|
rtt_adjusted_ns: rtt.adjusted.map(signed_duration_ns),
|
||||||
send_delay_ns: one_way
|
send_delay_ns: one_way
|
||||||
.and_then(|sample| sample.client_to_server)
|
.and_then(|sample| sample.client_to_server)
|
||||||
.map(duration_ns_i128),
|
.map(signed_duration_ns),
|
||||||
receive_delay_ns: one_way
|
receive_delay_ns: one_way
|
||||||
.and_then(|sample| sample.server_to_client)
|
.and_then(|sample| sample.server_to_client)
|
||||||
.map(duration_ns_i128),
|
.map(signed_duration_ns),
|
||||||
server_processing_ns: server_timing
|
server_processing_ns: server_timing
|
||||||
.and_then(|timing| timing.processing)
|
.and_then(|timing| timing.processing)
|
||||||
.map(duration_ns_i128),
|
.map(duration_ns_i128),
|
||||||
@@ -195,7 +203,7 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
||||||
duration.ns
|
duration.as_nanos()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,20 +12,21 @@ pub fn ts(ms: u64) -> ClientTimestamp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rtt(raw_ms: u64, effective_ms: i128) -> RttSample {
|
pub fn adjusted_rtt(raw_ms: u64, adjusted_ms: i128) -> RttSample {
|
||||||
|
let adjusted = SignedDuration::from_nanos(adjusted_ms * 1_000_000);
|
||||||
RttSample {
|
RttSample {
|
||||||
raw: Duration::from_millis(raw_ms),
|
raw: Duration::from_millis(raw_ms),
|
||||||
adjusted: u64::try_from(effective_ms).ok().map(Duration::from_millis),
|
adjusted: Some(adjusted),
|
||||||
effective: u64::try_from(effective_ms)
|
effective: adjusted,
|
||||||
.ok()
|
}
|
||||||
.map(Duration::from_millis)
|
}
|
||||||
.unwrap_or_else(|| Duration::from_millis(raw_ms)),
|
|
||||||
adjusted_signed: Some(SignedDuration {
|
pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
|
||||||
ns: effective_ms * 1_000_000,
|
let raw = Duration::from_millis(raw_ms);
|
||||||
}),
|
RttSample {
|
||||||
effective_signed: SignedDuration {
|
raw,
|
||||||
ns: effective_ms * 1_000_000,
|
adjusted: None,
|
||||||
},
|
effective: SignedDuration::from_duration(raw),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,15 @@ pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
pub fn adjusted_reply(seq: u32, raw_ms: u64, adjusted_ms: i128) -> ClientEvent {
|
||||||
|
reply_with_rtt(seq, raw_ms, adjusted_rtt(raw_ms, adjusted_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unadjusted_reply(seq: u32, raw_ms: u64) -> ClientEvent {
|
||||||
|
reply_with_rtt(seq, raw_ms, unadjusted_rtt(raw_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> ClientEvent {
|
||||||
let sent_at = ts(seq as u64 * 10);
|
let sent_at = ts(seq as u64 * 10);
|
||||||
let received_at = ClientTimestamp {
|
let received_at = ClientTimestamp {
|
||||||
mono: sent_at.mono + Duration::from_millis(raw_ms),
|
mono: sent_at.mono + Duration::from_millis(raw_ms),
|
||||||
@@ -52,7 +61,7 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
|||||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||||
sent_at,
|
sent_at,
|
||||||
received_at,
|
received_at,
|
||||||
rtt: rtt(raw_ms, effective_ms),
|
rtt,
|
||||||
server_timing: Some(ServerTiming {
|
server_timing: Some(ServerTiming {
|
||||||
receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000),
|
receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000),
|
||||||
receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000),
|
receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000),
|
||||||
@@ -63,8 +72,8 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
|||||||
processing: Some(Duration::from_millis(1)),
|
processing: Some(Duration::from_millis(1)),
|
||||||
}),
|
}),
|
||||||
one_way: Some(OneWayDelaySample {
|
one_way: Some(OneWayDelaySample {
|
||||||
client_to_server: Some(Duration::from_millis(1)),
|
client_to_server: Some(SignedDuration::from_nanos(1_000_000)),
|
||||||
server_to_client: Some(Duration::from_millis(2)),
|
server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
|
||||||
}),
|
}),
|
||||||
received_stats: Some(ReceivedStatsSample {
|
received_stats: Some(ReceivedStatsSample {
|
||||||
count: Some(seq + 1),
|
count: Some(seq + 1),
|
||||||
@@ -75,7 +84,11 @@ pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
pub fn unadjusted_late_reply(seq: u32, raw_ms: u64) -> ClientEvent {
|
||||||
|
late_reply_from(unadjusted_reply(seq, raw_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn late_reply_from(reply: ClientEvent) -> ClientEvent {
|
||||||
let ClientEvent::EchoReply {
|
let ClientEvent::EchoReply {
|
||||||
seq,
|
seq,
|
||||||
remote,
|
remote,
|
||||||
@@ -88,7 +101,7 @@ pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
|
|||||||
bytes,
|
bytes,
|
||||||
packet_meta,
|
packet_meta,
|
||||||
..
|
..
|
||||||
} = reply(seq, raw_ms, effective_ms)
|
} = reply
|
||||||
else {
|
else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
|
||||||
use common::{late_reply, reply, sent, ts};
|
use common::{adjusted_reply, sent, ts, unadjusted_late_reply, unadjusted_reply};
|
||||||
use irtt_client::{ClientEvent, PacketMeta};
|
use irtt_client::{
|
||||||
|
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
|
||||||
|
};
|
||||||
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
|
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn running_only_samples_avoid_finite_retention() {
|
fn running_only_samples_avoid_finite_retention() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
||||||
collector.process(&reply(0, 10, 9));
|
collector.process(&unadjusted_reply(0, 10));
|
||||||
collector.process(&reply(1, 20, 19));
|
collector.process(&unadjusted_reply(1, 20));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert_eq!(snapshot.rtt.primary.median_ns, None);
|
assert_eq!(snapshot.rtt.primary.median_ns, None);
|
||||||
assert_eq!(snapshot.rtt.raw.median_ns, None);
|
assert_eq!(snapshot.rtt.raw.median_ns, None);
|
||||||
|
assert_eq!(snapshot.rtt.adjusted.count, 0);
|
||||||
assert_eq!(snapshot.rtt.adjusted.median_ns, None);
|
assert_eq!(snapshot.rtt.adjusted.median_ns, None);
|
||||||
assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
|
assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
|
||||||
assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None);
|
assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None);
|
||||||
@@ -25,7 +28,7 @@ fn running_only_samples_avoid_finite_retention() {
|
|||||||
fn continuous_mode_tracks_running_samples_without_exact_medians() {
|
fn continuous_mode_tracks_running_samples_without_exact_medians() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
let mut collector = StatsCollector::new(StatsConfig::continuous());
|
||||||
for seq in 0..4104 {
|
for seq in 0..4104 {
|
||||||
collector.process(&reply(seq, 10, 10));
|
collector.process(&unadjusted_reply(seq, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
@@ -38,23 +41,24 @@ fn continuous_mode_tracks_running_samples_without_exact_medians() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
|
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&reply(0, 1, -2));
|
collector.process(&adjusted_reply(0, 1, -2));
|
||||||
collector.process(&reply(1, 10, 8));
|
collector.process(&adjusted_reply(1, 10, 8));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert_eq!(snapshot.rtt.primary.count, 2);
|
assert_eq!(snapshot.rtt.primary.count, 2);
|
||||||
assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000));
|
assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000));
|
||||||
assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0));
|
assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0));
|
||||||
assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000);
|
assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000);
|
||||||
|
assert_eq!(snapshot.rtt.adjusted.count, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn late_unique_counts_and_duplicates_do_not_update_measurements() {
|
fn late_unique_counts_and_duplicates_do_not_update_duplicate_measurements() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&sent(0, ts(0)));
|
collector.process(&sent(0, ts(0)));
|
||||||
collector.process(&sent(1, ts(10)));
|
collector.process(&sent(1, ts(10)));
|
||||||
collector.process(&reply(1, 10, 9));
|
collector.process(&unadjusted_reply(1, 10));
|
||||||
collector.process(&late_reply(0, 20, 19));
|
collector.process(&unadjusted_late_reply(0, 20));
|
||||||
collector.process(&ClientEvent::DuplicateReply {
|
collector.process(&ClientEvent::DuplicateReply {
|
||||||
seq: 0,
|
seq: 0,
|
||||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||||
@@ -68,6 +72,7 @@ fn late_unique_counts_and_duplicates_do_not_update_measurements() {
|
|||||||
assert_eq!(snapshot.packets.unique_replies, 2);
|
assert_eq!(snapshot.packets.unique_replies, 2);
|
||||||
assert_eq!(snapshot.packets.duplicates, 1);
|
assert_eq!(snapshot.packets.duplicates, 1);
|
||||||
assert_eq!(snapshot.packets.late_packets, 1);
|
assert_eq!(snapshot.packets.late_packets, 1);
|
||||||
|
assert_eq!(snapshot.packets.bytes_received, 64 + 64 + 64);
|
||||||
assert_eq!(snapshot.rtt.primary.count, 2);
|
assert_eq!(snapshot.rtt.primary.count, 2);
|
||||||
assert_eq!(snapshot.loss.lost_packets, 0);
|
assert_eq!(snapshot.loss.lost_packets, 0);
|
||||||
assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0);
|
assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0);
|
||||||
@@ -83,7 +88,7 @@ fn final_loss_uses_sent_minus_unique_replies_not_echo_loss_events() {
|
|||||||
sent_at: ts(0),
|
sent_at: ts(0),
|
||||||
timeout_at: Instant::now(),
|
timeout_at: Instant::now(),
|
||||||
});
|
});
|
||||||
collector.process(&late_reply(0, 10, 9));
|
collector.process(&unadjusted_late_reply(0, 10));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert_eq!(snapshot.events.loss_events, 1);
|
assert_eq!(snapshot.events.loss_events, 1);
|
||||||
@@ -110,12 +115,40 @@ fn assert_one_ipdv_pair(
|
|||||||
pair
|
pair
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reply_at(seq: u32, raw_ms: u64, sent_ms: u64) -> ClientEvent {
|
||||||
|
let sent_at = ClientTimestamp {
|
||||||
|
mono: Instant::now() + Duration::from_millis(sent_ms),
|
||||||
|
wall: UNIX_EPOCH + Duration::from_millis(sent_ms),
|
||||||
|
};
|
||||||
|
let received_at = ClientTimestamp {
|
||||||
|
mono: sent_at.mono + Duration::from_millis(raw_ms),
|
||||||
|
wall: sent_at.wall + Duration::from_millis(raw_ms),
|
||||||
|
};
|
||||||
|
let raw = Duration::from_millis(raw_ms);
|
||||||
|
ClientEvent::EchoReply {
|
||||||
|
seq,
|
||||||
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||||
|
sent_at,
|
||||||
|
received_at,
|
||||||
|
rtt: RttSample {
|
||||||
|
raw,
|
||||||
|
adjusted: None,
|
||||||
|
effective: SignedDuration::from_duration(raw),
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: None,
|
||||||
|
received_stats: None,
|
||||||
|
bytes: 64,
|
||||||
|
packet_meta: PacketMeta::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ipdv_is_sequence_adjacent_and_gap_preserving() {
|
fn ipdv_is_sequence_adjacent_and_gap_preserving() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
let first = collector.process(&reply(0, 10, 10));
|
let first = collector.process(&unadjusted_reply(0, 10));
|
||||||
let gap = collector.process(&reply(2, 15, 15));
|
let gap = collector.process(&unadjusted_reply(2, 15));
|
||||||
let adjacent = collector.process(&reply(3, 12, 12));
|
let adjacent = collector.process(&unadjusted_reply(3, 12));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert!(first.contributed_sample);
|
assert!(first.contributed_sample);
|
||||||
@@ -130,11 +163,44 @@ fn ipdv_is_sequence_adjacent_and_gap_preserving() {
|
|||||||
assert_eq!(snapshot.ipdv.round_trip.total_ns, 3_000_000);
|
assert_eq!(snapshot.ipdv.round_trip.total_ns, 3_000_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipdv_wraparound_sequence_is_adjacent() {
|
||||||
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
|
let first = collector.process(&reply_at(u32::MAX, 10, 0));
|
||||||
|
let wrapped = collector.process(&reply_at(0, 14, 10));
|
||||||
|
|
||||||
|
assert!(first.contributed_sample);
|
||||||
|
assert_no_ipdv_pairs(&first);
|
||||||
|
|
||||||
|
assert!(wrapped.contributed_sample);
|
||||||
|
assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4));
|
||||||
|
|
||||||
|
let snapshot = collector.snapshot();
|
||||||
|
assert_eq!(snapshot.ipdv.round_trip.count, 1);
|
||||||
|
assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipdv_wraparound_gap_is_preserved() {
|
||||||
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
|
let before_wrap = collector.process(&reply_at(u32::MAX - 1, 10, 0));
|
||||||
|
let wrapped = collector.process(&reply_at(0, 14, 20));
|
||||||
|
|
||||||
|
assert!(before_wrap.contributed_sample);
|
||||||
|
assert_no_ipdv_pairs(&before_wrap);
|
||||||
|
|
||||||
|
assert!(wrapped.contributed_sample);
|
||||||
|
assert_no_ipdv_pairs(&wrapped);
|
||||||
|
|
||||||
|
let snapshot = collector.snapshot();
|
||||||
|
assert_eq!(snapshot.ipdv.round_trip.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn late_reply_can_complete_ipdv_pair() {
|
fn late_reply_can_complete_ipdv_pair() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&reply(1, 20, 20));
|
collector.process(&unadjusted_reply(1, 20));
|
||||||
let update = collector.process(&late_reply(0, 10, 10));
|
let update = collector.process(&unadjusted_late_reply(0, 10));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
|
|
||||||
@@ -148,8 +214,8 @@ fn late_reply_can_complete_ipdv_pair() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn update_exposes_directional_ipdv_when_available() {
|
fn update_exposes_directional_ipdv_when_available() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&reply(0, 10, 10));
|
collector.process(&unadjusted_reply(0, 10));
|
||||||
let update = collector.process(&reply(1, 13, 13));
|
let update = collector.process(&unadjusted_reply(1, 13));
|
||||||
|
|
||||||
assert!(update.contributed_sample);
|
assert!(update.contributed_sample);
|
||||||
|
|
||||||
@@ -162,9 +228,9 @@ fn update_exposes_directional_ipdv_when_available() {
|
|||||||
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
|
|
||||||
let first = collector.process(&reply(0, 10, 10));
|
let first = collector.process(&unadjusted_reply(0, 10));
|
||||||
let gap = collector.process(&reply(2, 20, 20));
|
let gap = collector.process(&unadjusted_reply(2, 20));
|
||||||
let fill = collector.process(&reply(1, 13, 13));
|
let fill = collector.process(&unadjusted_reply(1, 13));
|
||||||
|
|
||||||
assert!(first.contributed_sample);
|
assert!(first.contributed_sample);
|
||||||
assert!(first.ipdv_pairs.is_empty());
|
assert!(first.ipdv_pairs.is_empty());
|
||||||
@@ -191,7 +257,7 @@ fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn server_processing_and_one_way_require_available_samples() {
|
fn server_processing_and_one_way_require_available_samples() {
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&reply(0, 10, 9));
|
collector.process(&unadjusted_reply(0, 10));
|
||||||
collector.process(&ClientEvent::LateReply {
|
collector.process(&ClientEvent::LateReply {
|
||||||
seq: 9,
|
seq: 9,
|
||||||
highest_seen: 10,
|
highest_seen: 10,
|
||||||
@@ -210,6 +276,62 @@ fn server_processing_and_one_way_require_available_samples() {
|
|||||||
assert_eq!(snapshot.server_processing.processing.count, 1);
|
assert_eq!(snapshot.server_processing.processing.count, 1);
|
||||||
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
|
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
|
||||||
assert_eq!(snapshot.events.untracked_late_replies, 1);
|
assert_eq!(snapshot.events.untracked_late_replies, 1);
|
||||||
|
assert_eq!(snapshot.packets.packets_received, 2);
|
||||||
|
assert_eq!(snapshot.packets.unique_replies, 1);
|
||||||
|
assert_eq!(snapshot.packets.late_packets, 1);
|
||||||
|
assert_eq!(snapshot.packets.bytes_received, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_one_way_delay_samples_are_included_in_stats() {
|
||||||
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
|
let sent_at = ts(0);
|
||||||
|
let received_at = ClientTimestamp {
|
||||||
|
mono: sent_at.mono + Duration::from_millis(10),
|
||||||
|
wall: sent_at.wall + Duration::from_millis(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
collector.process(&ClientEvent::EchoReply {
|
||||||
|
seq: 0,
|
||||||
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||||
|
sent_at,
|
||||||
|
received_at,
|
||||||
|
rtt: RttSample {
|
||||||
|
raw: Duration::from_millis(10),
|
||||||
|
adjusted: None,
|
||||||
|
effective: SignedDuration::from_nanos(10_000_000),
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: Some(OneWayDelaySample {
|
||||||
|
client_to_server: Some(SignedDuration::from_nanos(-1_000_000)),
|
||||||
|
server_to_client: Some(SignedDuration::from_nanos(-2_000_000)),
|
||||||
|
}),
|
||||||
|
received_stats: None,
|
||||||
|
bytes: 64,
|
||||||
|
packet_meta: PacketMeta::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let snapshot = collector.snapshot();
|
||||||
|
|
||||||
|
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
|
||||||
|
assert_eq!(snapshot.one_way_delay.send_delay.min_ns, Some(-1_000_000));
|
||||||
|
assert_eq!(snapshot.one_way_delay.send_delay.total_ns, -1_000_000);
|
||||||
|
assert_eq!(snapshot.one_way_delay.receive_delay.count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.one_way_delay.receive_delay.min_ns,
|
||||||
|
Some(-2_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.one_way_delay.receive_delay.total_ns, -2_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn older_cumulative_server_receive_count_does_not_regress() {
|
||||||
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
|
collector.process(&unadjusted_reply(1, 10));
|
||||||
|
collector.process(&unadjusted_late_reply(0, 10));
|
||||||
|
|
||||||
|
let snapshot = collector.snapshot();
|
||||||
|
assert_eq!(snapshot.packets.server_packets_received, Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -220,8 +342,8 @@ fn rolling_count_eviction_recomputes_from_bounded_events() {
|
|||||||
rolling_time: None,
|
rolling_time: None,
|
||||||
});
|
});
|
||||||
collector.process(&sent(0, ts(0)));
|
collector.process(&sent(0, ts(0)));
|
||||||
collector.process(&reply(0, 10, 10));
|
collector.process(&unadjusted_reply(0, 10));
|
||||||
collector.process(&reply(1, 20, 20));
|
collector.process(&unadjusted_reply(1, 20));
|
||||||
|
|
||||||
let rolling = collector.rolling_count().unwrap();
|
let rolling = collector.rolling_count().unwrap();
|
||||||
assert_eq!(rolling.packets.packets_sent, 0);
|
assert_eq!(rolling.packets.packets_sent, 0);
|
||||||
@@ -261,7 +383,7 @@ fn directional_loss_uses_server_received_count_when_available() {
|
|||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||||
collector.process(&sent(0, ts(0)));
|
collector.process(&sent(0, ts(0)));
|
||||||
collector.process(&sent(1, ts(10)));
|
collector.process(&sent(1, ts(10)));
|
||||||
collector.process(&reply(0, 10, 10));
|
collector.process(&unadjusted_reply(0, 10));
|
||||||
|
|
||||||
let loss = collector.snapshot().loss;
|
let loss = collector.snapshot().loss;
|
||||||
assert_eq!(loss.lost_packets, 1);
|
assert_eq!(loss.lost_packets, 1);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ allow = [
|
|||||||
"Apache-2.0",
|
"Apache-2.0",
|
||||||
"Unicode-3.0",
|
"Unicode-3.0",
|
||||||
"BSD-3-Clause",
|
"BSD-3-Clause",
|
||||||
|
"Zlib"
|
||||||
]
|
]
|
||||||
|
|
||||||
exceptions = [
|
exceptions = [
|
||||||
|
|||||||
+3
-1
@@ -4,10 +4,12 @@ members = ["cargo:."]
|
|||||||
# Config for 'dist'
|
# Config for 'dist'
|
||||||
[dist]
|
[dist]
|
||||||
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||||
cargo-dist-version = "0.31.0"
|
cargo-dist-version = "0.32.0"
|
||||||
# CI backends to support
|
# CI backends to support
|
||||||
ci = "github"
|
ci = "github"
|
||||||
# The installers to generate for each app
|
# The installers to generate for each app
|
||||||
installers = []
|
installers = []
|
||||||
# Target platforms to build apps for (Rust target-triple syntax)
|
# Target platforms to build apps for (Rust target-triple syntax)
|
||||||
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
|
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
|
||||||
|
# Features to pass to cargo build
|
||||||
|
features = ["full"]
|
||||||
@@ -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
+1137
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