Compare commits
1
Commits
v0.3.0
..
stats-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42fa20d2c |
No files matched your search
@@ -23,27 +23,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: actionlint handwritten workflows
|
- name: Run actionlint
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||||
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
|
||||||
@@ -56,11 +42,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||||
with:
|
with:
|
||||||
persona: regular
|
persona: regular
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -63,38 +63,6 @@ jobs:
|
|||||||
--locked \
|
--locked \
|
||||||
-- -D warnings
|
-- -D warnings
|
||||||
|
|
||||||
- name: Run clippy without default features
|
|
||||||
run: |
|
|
||||||
cargo clippy \
|
|
||||||
--workspace \
|
|
||||||
--all-targets \
|
|
||||||
--no-default-features \
|
|
||||||
--locked \
|
|
||||||
-- -D warnings
|
|
||||||
|
|
||||||
check:
|
|
||||||
name: cargo check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
|
||||||
with:
|
|
||||||
cache-key: check
|
|
||||||
|
|
||||||
- name: Run check without default features
|
|
||||||
run: |
|
|
||||||
cargo check \
|
|
||||||
--workspace \
|
|
||||||
--no-default-features \
|
|
||||||
--locked
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: cargo test
|
name: cargo test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -102,7 +70,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -119,13 +87,6 @@ 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
|
||||||
@@ -136,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
name: Netem Interop
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
GOBIN: ${{ github.workspace }}/.tools/bin
|
|
||||||
IRTT_UPSTREAM_VERSION: v0.9.1
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
netem:
|
|
||||||
name: netem interop
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 25
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
|
||||||
with:
|
|
||||||
cache-key: interop-netem
|
|
||||||
|
|
||||||
- name: Check host tools
|
|
||||||
run: |
|
|
||||||
ip -V
|
|
||||||
tc -V
|
|
||||||
go version
|
|
||||||
|
|
||||||
- name: Install upstream irtt
|
|
||||||
run: |
|
|
||||||
mkdir -p "$GOBIN"
|
|
||||||
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
|
|
||||||
"$GOBIN/irtt" version
|
|
||||||
|
|
||||||
- name: Build irtt-rs CLI
|
|
||||||
run: cargo build -p irtt-cli --locked
|
|
||||||
|
|
||||||
- name: Run netem interop comparisons
|
|
||||||
run: |
|
|
||||||
python3 scripts/run_netem_interop_ci.py \
|
|
||||||
--all \
|
|
||||||
--skip-build \
|
|
||||||
--upstream-irtt "$GOBIN/irtt" \
|
|
||||||
--irtt-rs-command "$PWD/target/debug/irtt-cli"
|
|
||||||
|
|
||||||
- name: Append netem interop summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
|
||||||
if [ -n "$latest_summary" ]; then
|
|
||||||
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload netem interop artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
||||||
with:
|
|
||||||
name: interop-netem
|
|
||||||
path: target/interop-netem
|
|
||||||
@@ -64,9 +64,9 @@ jobs:
|
|||||||
# we specify bash to get pipefail; it guards against the `curl` command
|
# 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.32.0/cargo-dist-installer.sh | sh"
|
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
|
||||||
- name: Cache dist
|
- name: Cache dist
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
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@v7
|
uses: actions/upload-artifact@v6
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
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@v7
|
uses: actions/upload-artifact@v6
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
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@v7
|
uses: actions/upload-artifact@v6
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
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@v7
|
uses: actions/upload-artifact@v6
|
||||||
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@v8
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
pattern: artifacts-*
|
pattern: artifacts-*
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
- name: Upload Clippy SARIF
|
- name: Upload Clippy SARIF
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: rust-clippy-results.sarif
|
sarif_file: rust-clippy-results.sarif
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run cargo-deny
|
- name: Run cargo-deny
|
||||||
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
|
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2
|
||||||
with:
|
with:
|
||||||
command: check
|
command: check
|
||||||
arguments: --all-features
|
arguments: --all-features
|
||||||
@@ -56,24 +56,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: actionlint handwritten workflows
|
- name: Run actionlint
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||||
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
+18
-523
@@ -2,12 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 3
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -44,7 +38,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -55,14 +49,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.0"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -82,21 +76,6 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cassowary"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "castaway"
|
|
||||||
version = "0.2.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
|
||||||
dependencies = [
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -151,9 +130,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmov"
|
name = "cmov"
|
||||||
version = "0.5.4"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
@@ -161,56 +140,17 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "compact_str"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
|
||||||
dependencies = [
|
|
||||||
"castaway",
|
|
||||||
"cfg-if",
|
|
||||||
"itoa",
|
|
||||||
"rustversion",
|
|
||||||
"ryu",
|
|
||||||
"static_assertions",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"crossterm_winapi",
|
|
||||||
"mio",
|
|
||||||
"parking_lot",
|
|
||||||
"rustix",
|
|
||||||
"signal-hook",
|
|
||||||
"signal-hook-mio",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm_winapi"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.2"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
@@ -223,7 +163,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"nix",
|
"nix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -235,40 +175,6 @@ dependencies = [
|
|||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "darling"
|
|
||||||
version = "0.23.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
|
||||||
dependencies = [
|
|
||||||
"darling_core",
|
|
||||||
"darling_macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "darling_core"
|
|
||||||
version = "0.23.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
|
||||||
dependencies = [
|
|
||||||
"ident_case",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"strsim",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "darling_macro"
|
|
||||||
version = "0.23.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
|
||||||
dependencies = [
|
|
||||||
"darling_core",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -293,45 +199,6 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "either"
|
|
||||||
version = "1.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "equivalent"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -356,50 +223,20 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ident_case"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "indoc"
|
|
||||||
version = "2.0.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
|
||||||
dependencies = [
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "instability"
|
|
||||||
version = "0.3.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
|
||||||
dependencies = [
|
|
||||||
"darling",
|
|
||||||
"indoc",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-cli"
|
name = "irtt-cli"
|
||||||
version = "0.3.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-stats",
|
"irtt-stats",
|
||||||
"ratatui",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.3.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -409,7 +246,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-proto"
|
name = "irtt-proto"
|
||||||
version = "0.3.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -419,7 +256,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.3.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
]
|
]
|
||||||
@@ -430,57 +267,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itertools"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
|
||||||
dependencies = [
|
|
||||||
"either",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itoa"
|
|
||||||
version = "1.0.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.4.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lock_api"
|
|
||||||
version = "0.4.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
|
||||||
dependencies = [
|
|
||||||
"scopeguard",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "log"
|
|
||||||
version = "0.4.29"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru"
|
|
||||||
version = "0.12.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -491,18 +283,6 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mio"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"wasi",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.31.3"
|
version = "0.31.3"
|
||||||
@@ -536,35 +316,6 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot"
|
|
||||||
version = "0.12.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
|
||||||
dependencies = [
|
|
||||||
"lock_api",
|
|
||||||
"parking_lot_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot_core"
|
|
||||||
version = "0.9.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"redox_syscall",
|
|
||||||
"smallvec",
|
|
||||||
"windows-link",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "paste"
|
|
||||||
version = "1.0.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -583,148 +334,22 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ratatui"
|
|
||||||
version = "0.29.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"cassowary",
|
|
||||||
"compact_str",
|
|
||||||
"crossterm",
|
|
||||||
"indoc",
|
|
||||||
"instability",
|
|
||||||
"itertools",
|
|
||||||
"lru",
|
|
||||||
"paste",
|
|
||||||
"strum",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-truncate",
|
|
||||||
"unicode-width 0.2.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_syscall"
|
|
||||||
version = "0.5.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "0.38.44"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustversion"
|
|
||||||
version = "1.0.22"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ryu"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scopeguard"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook"
|
|
||||||
version = "0.3.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"signal-hook-registry",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-mio"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"mio",
|
|
||||||
"signal-hook",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-registry"
|
|
||||||
version = "1.4.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
|
||||||
dependencies = [
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "smallvec"
|
|
||||||
version = "1.15.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.4"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "static_assertions"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum"
|
|
||||||
version = "0.26.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
|
||||||
dependencies = [
|
|
||||||
"strum_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum_macros"
|
|
||||||
version = "0.26.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
|
||||||
dependencies = [
|
|
||||||
"heck",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"rustversion",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -764,9 +389,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
@@ -774,84 +399,18 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-segmentation"
|
|
||||||
version = "1.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-truncate"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
|
||||||
dependencies = [
|
|
||||||
"itertools",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-width 0.1.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.1.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-i686-pc-windows-gnu",
|
|
||||||
"winapi-x86_64-pc-windows-gnu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-i686-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.59.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -860,67 +419,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
|
||||||
dependencies = [
|
|
||||||
"windows_aarch64_gnullvm",
|
|
||||||
"windows_aarch64_msvc",
|
|
||||||
"windows_i686_gnu",
|
|
||||||
"windows_i686_gnullvm",
|
|
||||||
"windows_i686_msvc",
|
|
||||||
"windows_x86_64_gnu",
|
|
||||||
"windows_x86_64_gnullvm",
|
|
||||||
"windows_x86_64_msvc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
|
||||||
+3
-3
@@ -16,9 +16,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" }
|
||||||
irtt-client = { version = "0.3.0", path = "crates/irtt-client" }
|
irtt-client = { version = "0.2.0", path = "crates/irtt-client" }
|
||||||
irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" }
|
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -10,17 +10,13 @@ It is not the upstream IRTT project. For the original implementation, protocol b
|
|||||||
|
|
||||||
This repository currently provides:
|
This repository currently provides:
|
||||||
|
|
||||||
- `irtt-rs`, the canonical multi-applet binary
|
- `irtt-cli`, a command-line IRTT-compatible client
|
||||||
- `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 stream output modes
|
- human, simple, machine-readable, and RTT-only output modes
|
||||||
- optional local summary statistics
|
- optional local summary statistics
|
||||||
|
|
||||||
Server support is not implemented.
|
Server support is not implemented.
|
||||||
The installed binary is intentionally not named `irtt` to avoid conflicting or
|
|
||||||
confusing overlap with upstream IRTT.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -34,24 +30,12 @@ cd irtt-rs
|
|||||||
cargo install --path crates/irtt-cli
|
cargo install --path crates/irtt-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
The default install provides the normal client-oriented binaries, including
|
|
||||||
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo install --path crates/irtt-cli --features tui
|
|
||||||
```
|
|
||||||
|
|
||||||
Or run it directly without installing:
|
Or run it directly without installing:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p irtt-cli -- <server>
|
cargo run -p irtt-cli -- <server>
|
||||||
```
|
```
|
||||||
|
|
||||||
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
|
|
||||||
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`,
|
|
||||||
`irtt-tui`, and future server applet names to it. Applet dispatch is based on
|
|
||||||
the invoked binary name.
|
|
||||||
|
|
||||||
## CLI usage
|
## CLI usage
|
||||||
|
|
||||||
Basic test:
|
Basic test:
|
||||||
@@ -81,22 +65,10 @@ irtt-cli <server> --output machine
|
|||||||
irtt-cli <server> --output rtt-us
|
irtt-cli <server> --output rtt-us
|
||||||
```
|
```
|
||||||
|
|
||||||
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
|
|
||||||
interactive probing. It defaults to continuous probing, equivalent to
|
|
||||||
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
|
|
||||||
session gracefully.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-tui <server>
|
|
||||||
irtt-tui <server> --duration 30s
|
|
||||||
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
|
|
||||||
```
|
|
||||||
|
|
||||||
For available options:
|
For available options:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli --help
|
irtt-cli --help
|
||||||
irtt-tui --help
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Machine output
|
## Machine output
|
||||||
@@ -112,16 +84,10 @@ irtt-cli <server> --duration 0 --interval 250ms --output machine
|
|||||||
Each line represents one client event and starts with an `event` field, for example:
|
Each line represents one client event and starts with an `event` field, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none
|
event=echo_reply seq=4 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
|
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
|
||||||
RTT fields are reported in microseconds. `raw_rtt_us` is the measured client
|
|
||||||
send-to-receive RTT. `effective_rtt_us` and `adjusted_rtt_us` are signed and may
|
|
||||||
be negative when server processing exceeds the raw RTT or timing correction
|
|
||||||
produces a negative adjusted value. When one-way delay fields are present,
|
|
||||||
`client_to_server_us` and `server_to_client_us` are also signed microseconds and
|
|
||||||
may be negative due to wall-clock skew.
|
|
||||||
|
|
||||||
For consumers that only need RTT values, use:
|
For consumers that only need RTT values, use:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-cli"
|
name = "irtt-cli"
|
||||||
version = "0.3.0"
|
version = "0.2.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,34 +11,13 @@ readme.workspace = true
|
|||||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||||
categories = ["command-line-utilities", "network-programming"]
|
categories = ["command-line-utilities", "network-programming"]
|
||||||
|
|
||||||
[lib]
|
|
||||||
doc = false
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "irtt-rs"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "irtt-cli"
|
|
||||||
path = "src/main.rs"
|
|
||||||
required-features = ["client"]
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "irtt-tui"
|
|
||||||
path = "src/main.rs"
|
|
||||||
required-features = ["tui"]
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["client", "stats"]
|
default = ["stats"]
|
||||||
client = ["dep:irtt-client", "dep:irtt-proto"]
|
stats = ["dep:irtt-stats"]
|
||||||
stats = ["client", "dep:irtt-stats"]
|
|
||||||
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
crossterm = { version = "0.28.1", optional = true }
|
|
||||||
ctrlc = "3.5.2"
|
ctrlc = "3.5.2"
|
||||||
irtt-client = { workspace = true, optional = true }
|
irtt-client.workspace = true
|
||||||
irtt-proto = { workspace = true, optional = true }
|
irtt-proto.workspace = true
|
||||||
irtt-stats = { workspace = true, optional = true }
|
irtt-stats = { workspace = true, optional = true }
|
||||||
ratatui = { version = "0.29.0", optional = true }
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum RequestedApplet {
|
|
||||||
Client,
|
|
||||||
Tui,
|
|
||||||
Server,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AppletAvailability {
|
|
||||||
Client,
|
|
||||||
Tui,
|
|
||||||
Server,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
|
||||||
match Path::new(argv0)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(argv0)
|
|
||||||
{
|
|
||||||
"irtt-cli" => Some(RequestedApplet::Client),
|
|
||||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
|
||||||
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
|
||||||
"irtt-rs" => None,
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn default_applet_for_features(
|
|
||||||
client_enabled: bool,
|
|
||||||
tui_enabled: bool,
|
|
||||||
server_enabled: bool,
|
|
||||||
) -> AppletAvailability {
|
|
||||||
if client_enabled {
|
|
||||||
AppletAvailability::Client
|
|
||||||
} else if tui_enabled {
|
|
||||||
AppletAvailability::Tui
|
|
||||||
} else if server_enabled {
|
|
||||||
AppletAvailability::Server
|
|
||||||
} else {
|
|
||||||
AppletAvailability::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn applet_detection_maps_binary_names() {
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("/usr/bin/irtt-cli"),
|
|
||||||
Some(RequestedApplet::Client)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("irtt-tui"),
|
|
||||||
Some(RequestedApplet::Tui)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("irtt-server"),
|
|
||||||
Some(RequestedApplet::Server)
|
|
||||||
);
|
|
||||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
|
||||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_applet_prefers_client_then_tui() {
|
|
||||||
assert_eq!(
|
|
||||||
default_applet_for_features(true, true, false),
|
|
||||||
AppletAvailability::Client
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
default_applet_for_features(false, true, false),
|
|
||||||
AppletAvailability::Tui
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
default_applet_for_features(false, false, false),
|
|
||||||
AppletAvailability::None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
use irtt_client::ClientConfig;
|
|
||||||
|
|
||||||
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg};
|
|
||||||
|
|
||||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
|
||||||
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
|
|
||||||
pub struct ClientArgs {
|
|
||||||
/// Server address or host, with optional port.
|
|
||||||
pub server: String,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
default_value = "10s",
|
|
||||||
value_parser = parse_test_duration,
|
|
||||||
help = "Test duration; use 0 for continuous mode",
|
|
||||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
|
|
||||||
)]
|
|
||||||
pub duration: Duration,
|
|
||||||
|
|
||||||
#[command(flatten)]
|
|
||||||
pub common: CommonClientArgs,
|
|
||||||
|
|
||||||
/// Output format.
|
|
||||||
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
|
|
||||||
pub output: OutputMode,
|
|
||||||
|
|
||||||
/// Include extra fields in human output.
|
|
||||||
#[arg(long)]
|
|
||||||
pub verbose: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientArgs {
|
|
||||||
pub fn to_client_config(&self) -> ClientConfig {
|
|
||||||
self.common.to_client_config(&self.server, self.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
|
||||||
self.duration == Duration::ZERO
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
|
||||||
self.common.tstamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for ClientArgs {
|
|
||||||
type Target = CommonClientArgs;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.common
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
||||||
pub enum OutputMode {
|
|
||||||
/// Readable terminal output with a final summary.
|
|
||||||
Human,
|
|
||||||
/// Parseable full event fields.
|
|
||||||
Machine,
|
|
||||||
/// Simple key=value-ish event stream.
|
|
||||||
Simple,
|
|
||||||
/// RTT microseconds only.
|
|
||||||
RttUs,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OutputMode {
|
|
||||||
pub fn prints_summary(self) -> bool {
|
|
||||||
matches!(self, Self::Human)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use clap::{CommandFactory, Parser};
|
|
||||||
use irtt_client::NegotiationPolicy;
|
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-cli"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
ClientArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_parser_keeps_finite_default_duration() {
|
|
||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(args.server, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
|
||||||
assert_eq!(args.interval, Duration::from_secs(1));
|
|
||||||
assert_eq!(args.output, OutputMode::Human);
|
|
||||||
assert!(!args.is_continuous());
|
|
||||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
|
||||||
|
|
||||||
let config = args.to_client_config();
|
|
||||||
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
|
|
||||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
|
||||||
assert_eq!(config.stamp_at, StampAt::Both);
|
|
||||||
assert_eq!(config.clock, Clock::Both);
|
|
||||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
|
|
||||||
for output in ["human", "simple", "machine", "rtt-us"] {
|
|
||||||
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
|
|
||||||
}
|
|
||||||
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_help_lists_shared_protocol_options() {
|
|
||||||
let help = ClientArgs::command().render_help().to_string();
|
|
||||||
assert!(help.contains("--output <OUTPUT>"));
|
|
||||||
assert!(help.contains("--tstamp <MODE>"));
|
|
||||||
assert!(help.contains("--stats <STATS>"));
|
|
||||||
assert!(help.contains("--sfill <STRING>"));
|
|
||||||
assert!(help.contains("--dscp <0..=63>"));
|
|
||||||
assert!(help.contains("--ttl <1..=255>"));
|
|
||||||
assert!(help.contains("--loose"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_mode_summary_policy_is_human_only() {
|
|
||||||
assert!(OutputMode::Human.prints_summary());
|
|
||||||
assert!(!OutputMode::Simple.prints_summary());
|
|
||||||
assert!(!OutputMode::Machine.prints_summary());
|
|
||||||
assert!(!OutputMode::RttUs.prints_summary());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
pub mod args;
|
|
||||||
pub mod output;
|
|
||||||
pub mod run;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
pub mod summary;
|
|
||||||
|
|
||||||
pub use args::{ClientArgs, OutputMode};
|
|
||||||
pub use output::{
|
|
||||||
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
|
|
||||||
HumanIpdvPair, HumanOutputOptions,
|
|
||||||
};
|
|
||||||
pub use run::run_stream;
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
use std::{
|
|
||||||
fmt::Write as _,
|
|
||||||
net::SocketAddr,
|
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_client::{
|
|
||||||
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
|
||||||
ServerTiming, SignedDuration, WarningKind,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::args::OutputMode;
|
|
||||||
|
|
||||||
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
|
|
||||||
if matches!(event, ClientEvent::EchoSent { .. }) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match mode {
|
|
||||||
OutputMode::RttUs => format_rtt_us(event),
|
|
||||||
OutputMode::Human => Some(format_human_event(event, None)),
|
|
||||||
OutputMode::Machine => Some(format_machine(event)),
|
|
||||||
OutputMode::Simple => Some(format_simple(event)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct HumanEventStats {
|
|
||||||
pub contributed_sample: bool,
|
|
||||||
pub ipdv_pairs: Vec<HumanIpdvPair>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct HumanIpdvPair {
|
|
||||||
pub previous_seq: u32,
|
|
||||||
pub current_seq: u32,
|
|
||||||
pub rtt_ipdv: Duration,
|
|
||||||
pub send_ipdv: Option<Duration>,
|
|
||||||
pub receive_ipdv: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct HumanOutputOptions {
|
|
||||||
pub verbose: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
impl From<irtt_stats::EventStatsUpdate> for HumanEventStats {
|
|
||||||
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
contributed_sample: value.contributed_sample,
|
|
||||||
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
impl From<irtt_stats::IpdvPairUpdate> for HumanIpdvPair {
|
|
||||||
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
previous_seq: value.previous_seq,
|
|
||||||
current_seq: value.current_seq,
|
|
||||||
rtt_ipdv: value.rtt_ipdv,
|
|
||||||
send_ipdv: value.send_ipdv,
|
|
||||||
receive_ipdv: value.receive_ipdv,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_human_event(event: &ClientEvent, stats: Option<HumanEventStats>) -> String {
|
|
||||||
format_human_event_with_options(event, stats, HumanOutputOptions::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_human_event_with_options(
|
|
||||||
event: &ClientEvent,
|
|
||||||
stats: Option<HumanEventStats>,
|
|
||||||
options: HumanOutputOptions,
|
|
||||||
) -> String {
|
|
||||||
let stats = stats.as_ref();
|
|
||||||
|
|
||||||
match event {
|
|
||||||
ClientEvent::SessionStarted { remote, token, .. } => {
|
|
||||||
format!("session started remote={remote} token={token:#x}")
|
|
||||||
}
|
|
||||||
ClientEvent::NoTestCompleted { remote, .. } => {
|
|
||||||
format!("no-test completed remote={remote}")
|
|
||||||
}
|
|
||||||
ClientEvent::SessionClosed { remote, token, .. } => {
|
|
||||||
format!("session closed remote={remote} token={token:#x}")
|
|
||||||
}
|
|
||||||
ClientEvent::EchoSent { .. } => String::new(),
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
rtt,
|
|
||||||
server_timing,
|
|
||||||
one_way,
|
|
||||||
received_stats,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut out = format!("seq={seq}");
|
|
||||||
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
|
|
||||||
write_human_one_way(&mut out, *one_way);
|
|
||||||
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
|
|
||||||
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
|
|
||||||
write!(out, " proc={}", format_duration(processing)).unwrap();
|
|
||||||
}
|
|
||||||
if options.verbose {
|
|
||||||
write_human_received_stats(&mut out, *received_stats);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
ClientEvent::EchoLoss { seq, .. } => {
|
|
||||||
format!("loss seq={seq}")
|
|
||||||
}
|
|
||||||
ClientEvent::DuplicateReply { seq, remote, .. } => {
|
|
||||||
format!("duplicate seq={seq} remote={remote}")
|
|
||||||
}
|
|
||||||
ClientEvent::LateReply {
|
|
||||||
seq,
|
|
||||||
highest_seen,
|
|
||||||
remote,
|
|
||||||
rtt,
|
|
||||||
one_way,
|
|
||||||
received_stats,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
|
|
||||||
if let Some(rtt) = rtt {
|
|
||||||
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
|
|
||||||
write_human_one_way(&mut out, *one_way);
|
|
||||||
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
|
|
||||||
}
|
|
||||||
write_human_received_stats(&mut out, *received_stats);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
ClientEvent::Warning { kind, message, .. } => {
|
|
||||||
format!("warning kind={} message={message}", warning_kind(*kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_rtt_us(event: &ClientEvent) -> Option<String> {
|
|
||||||
match event {
|
|
||||||
ClientEvent::EchoReply { rtt, .. } => Some(signed_duration_us(rtt.effective).to_string()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_machine(event: &ClientEvent) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
match event {
|
|
||||||
ClientEvent::SessionStarted {
|
|
||||||
remote,
|
|
||||||
token,
|
|
||||||
negotiated,
|
|
||||||
at,
|
|
||||||
} => {
|
|
||||||
write_common(&mut out, "session_started");
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write_token(&mut out, *token);
|
|
||||||
write_wall(&mut out, "event_wall_ns", at.wall);
|
|
||||||
write_negotiated(&mut out, negotiated);
|
|
||||||
}
|
|
||||||
ClientEvent::NoTestCompleted {
|
|
||||||
remote,
|
|
||||||
negotiated,
|
|
||||||
at,
|
|
||||||
} => {
|
|
||||||
write_common(&mut out, "no_test_completed");
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write_wall(&mut out, "event_wall_ns", at.wall);
|
|
||||||
write_negotiated(&mut out, negotiated);
|
|
||||||
}
|
|
||||||
ClientEvent::SessionClosed { remote, token, at } => {
|
|
||||||
write_common(&mut out, "session_closed");
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write_token(&mut out, *token);
|
|
||||||
write_wall(&mut out, "event_wall_ns", at.wall);
|
|
||||||
}
|
|
||||||
ClientEvent::EchoSent { .. } => {}
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
remote,
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt,
|
|
||||||
server_timing,
|
|
||||||
one_way,
|
|
||||||
received_stats,
|
|
||||||
bytes: _,
|
|
||||||
packet_meta,
|
|
||||||
} => {
|
|
||||||
write_common(&mut out, "echo_reply");
|
|
||||||
write_seq(&mut out, *seq);
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
|
|
||||||
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
|
|
||||||
write_rtt(&mut out, rtt);
|
|
||||||
write_server_timing(&mut out, *server_timing);
|
|
||||||
write_one_way(&mut out, *one_way);
|
|
||||||
write_received_stats(&mut out, *received_stats);
|
|
||||||
write_packet_meta(&mut out, *packet_meta);
|
|
||||||
}
|
|
||||||
ClientEvent::EchoLoss { seq, sent_at, .. } => {
|
|
||||||
write_common(&mut out, "loss");
|
|
||||||
write_seq(&mut out, *seq);
|
|
||||||
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
|
|
||||||
out.push_str(" warning=loss");
|
|
||||||
}
|
|
||||||
ClientEvent::DuplicateReply {
|
|
||||||
seq,
|
|
||||||
remote,
|
|
||||||
received_at,
|
|
||||||
bytes: _,
|
|
||||||
} => {
|
|
||||||
write_common(&mut out, "duplicate");
|
|
||||||
write_seq(&mut out, *seq);
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
|
|
||||||
out.push_str(" warning=duplicate");
|
|
||||||
}
|
|
||||||
ClientEvent::LateReply {
|
|
||||||
seq,
|
|
||||||
highest_seen,
|
|
||||||
remote,
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt,
|
|
||||||
server_timing,
|
|
||||||
one_way,
|
|
||||||
received_stats,
|
|
||||||
bytes: _,
|
|
||||||
packet_meta,
|
|
||||||
} => {
|
|
||||||
write_common(&mut out, "late");
|
|
||||||
write_seq(&mut out, *seq);
|
|
||||||
write_remote(&mut out, *remote);
|
|
||||||
write!(out, " highest_seen={highest_seen}").unwrap();
|
|
||||||
if let Some(sent_at) = sent_at {
|
|
||||||
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
|
|
||||||
}
|
|
||||||
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
|
|
||||||
if let Some(rtt) = rtt {
|
|
||||||
write_rtt(&mut out, rtt);
|
|
||||||
}
|
|
||||||
write_server_timing(&mut out, *server_timing);
|
|
||||||
write_one_way(&mut out, *one_way);
|
|
||||||
write_received_stats(&mut out, *received_stats);
|
|
||||||
write_packet_meta(&mut out, *packet_meta);
|
|
||||||
out.push_str(" warning=late");
|
|
||||||
}
|
|
||||||
ClientEvent::Warning { kind, message, at } => {
|
|
||||||
write_common(&mut out, "warning");
|
|
||||||
write_wall(&mut out, "at", at.wall);
|
|
||||||
write!(
|
|
||||||
out,
|
|
||||||
" warning_kind={} message={}",
|
|
||||||
warning_kind(*kind),
|
|
||||||
escape_value(message)
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_simple(event: &ClientEvent) -> String {
|
|
||||||
match event {
|
|
||||||
ClientEvent::SessionStarted { remote, token, .. } => {
|
|
||||||
format!("session started remote={remote} token={token:#x}")
|
|
||||||
}
|
|
||||||
ClientEvent::NoTestCompleted { remote, .. } => {
|
|
||||||
format!("no-test completed remote={remote}")
|
|
||||||
}
|
|
||||||
ClientEvent::SessionClosed { remote, token, .. } => {
|
|
||||||
format!("session closed remote={remote} token={token:#x}")
|
|
||||||
}
|
|
||||||
ClientEvent::EchoSent { .. } => String::new(),
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
remote,
|
|
||||||
rtt,
|
|
||||||
server_timing,
|
|
||||||
bytes: _,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut out = format!(
|
|
||||||
"reply seq={seq} remote={remote} rtt_us={}",
|
|
||||||
signed_duration_us(rtt.effective)
|
|
||||||
);
|
|
||||||
if rtt.adjusted.is_some() {
|
|
||||||
write!(out, " raw_rtt_us={}", duration_us(rtt.raw)).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
|
|
||||||
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
ClientEvent::EchoLoss { seq, .. } => {
|
|
||||||
format!("loss seq={seq}")
|
|
||||||
}
|
|
||||||
ClientEvent::DuplicateReply { seq, remote, .. } => {
|
|
||||||
format!("duplicate seq={seq} remote={remote}")
|
|
||||||
}
|
|
||||||
ClientEvent::LateReply {
|
|
||||||
seq,
|
|
||||||
highest_seen,
|
|
||||||
remote,
|
|
||||||
rtt,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
|
|
||||||
if let Some(rtt) = rtt {
|
|
||||||
write!(out, " rtt_us={}", signed_duration_us(rtt.effective)).unwrap();
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
ClientEvent::Warning { kind, message, .. } => {
|
|
||||||
format!("warning kind={} message={message}", warning_kind(*kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_common(out: &mut String, event: &str) {
|
|
||||||
write!(out, "event={event}").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_seq(out: &mut String, seq: u32) {
|
|
||||||
write!(out, " seq={seq}").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_remote(out: &mut String, remote: SocketAddr) {
|
|
||||||
write!(out, " remote={remote}").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_token(out: &mut String, token: u64) {
|
|
||||||
write!(out, " token={token:#x}").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
|
|
||||||
if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
|
|
||||||
write!(out, " {key}={}", duration.as_nanos()).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
|
|
||||||
write!(
|
|
||||||
out,
|
|
||||||
" duration_ns={} interval_ns={} payload_length={}",
|
|
||||||
negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_rtt(out: &mut String, rtt: &RttSample) {
|
|
||||||
write!(
|
|
||||||
out,
|
|
||||||
" raw_rtt_us={} effective_rtt_us={}",
|
|
||||||
duration_us(rtt.raw),
|
|
||||||
signed_duration_us(rtt.effective)
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if let Some(adjusted) = rtt.adjusted {
|
|
||||||
write!(out, " adjusted_rtt_us={}", signed_duration_us(adjusted)).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
|
|
||||||
if let Some(timing) = timing {
|
|
||||||
write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
|
|
||||||
write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
|
|
||||||
write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
|
|
||||||
write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
|
|
||||||
write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
|
|
||||||
write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
|
|
||||||
if let Some(processing) = timing.processing {
|
|
||||||
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
|
|
||||||
if let Some(one_way) = one_way {
|
|
||||||
if let Some(value) = one_way.client_to_server {
|
|
||||||
write!(out, " client_to_server_us={}", signed_duration_us(value)).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(value) = one_way.server_to_client {
|
|
||||||
write!(out, " server_to_client_us={}", signed_duration_us(value)).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
|
|
||||||
if let Some(stats) = stats {
|
|
||||||
if let Some(count) = stats.count {
|
|
||||||
write!(out, " server_received_count={count}").unwrap();
|
|
||||||
}
|
|
||||||
if let Some(window) = stats.window {
|
|
||||||
write!(out, " server_received_window={window:#x}").unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_packet_meta(out: &mut String, meta: PacketMeta) {
|
|
||||||
write_optional_u8(out, "traffic_class", meta.traffic_class);
|
|
||||||
write_optional_u8(out, "dscp", meta.dscp);
|
|
||||||
write_optional_u8(out, "ecn", meta.ecn);
|
|
||||||
match meta.kernel_rx_timestamp {
|
|
||||||
Some(timestamp) => write_wall(out, "kernel_rx_ns", timestamp),
|
|
||||||
None => write!(out, " kernel_rx_ns=none").unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_human_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
|
|
||||||
match one_way {
|
|
||||||
Some(one_way) => {
|
|
||||||
write!(
|
|
||||||
out,
|
|
||||||
" rd={} sd={}",
|
|
||||||
format_optional_signed_duration(one_way.server_to_client),
|
|
||||||
format_optional_signed_duration(one_way.client_to_server)
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
None => out.push_str(" rd=n/a sd=n/a"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_human_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
|
|
||||||
if let Some(stats) = stats {
|
|
||||||
if let Some(count) = stats.count {
|
|
||||||
write!(out, " server_received={count}").unwrap();
|
|
||||||
}
|
|
||||||
if let Some(window) = stats.window {
|
|
||||||
write!(out, " server_window={window:#x}").unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_human_ipdv(stats: Option<&HumanEventStats>, seq: u32) -> String {
|
|
||||||
let Some(stats) = stats else {
|
|
||||||
return "n/a".to_owned();
|
|
||||||
};
|
|
||||||
|
|
||||||
let pair = stats
|
|
||||||
.ipdv_pairs
|
|
||||||
.iter()
|
|
||||||
.find(|pair| pair.current_seq == seq)
|
|
||||||
.or_else(|| {
|
|
||||||
stats
|
|
||||||
.ipdv_pairs
|
|
||||||
.iter()
|
|
||||||
.find(|pair| pair.previous_seq == seq)
|
|
||||||
});
|
|
||||||
|
|
||||||
pair.map(|pair| format_duration(pair.rtt_ipdv))
|
|
||||||
.unwrap_or_else(|| "n/a".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_optional_u8(out: &mut String, key: &str, value: Option<u8>) {
|
|
||||||
match value {
|
|
||||||
Some(value) => write!(out, " {key}={value}").unwrap(),
|
|
||||||
None => write!(out, " {key}=none").unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
|
|
||||||
if let Some(value) = value {
|
|
||||||
write!(out, " {key}={value}").unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn duration_us(duration: Duration) -> u128 {
|
|
||||||
duration.as_micros()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signed_duration_us(duration: SignedDuration) -> i128 {
|
|
||||||
duration.as_micros()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_optional_signed_duration(duration: Option<SignedDuration>) -> String {
|
|
||||||
duration
|
|
||||||
.map(format_signed_duration)
|
|
||||||
.unwrap_or_else(|| "n/a".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_duration(duration: Duration) -> String {
|
|
||||||
format_ns(duration.as_nanos() as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_signed_duration(duration: SignedDuration) -> String {
|
|
||||||
format_signed_ns(duration.as_nanos() as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_signed_ns(ns: f64) -> String {
|
|
||||||
if ns < 0.0 {
|
|
||||||
format!("-{}", format_ns(-ns))
|
|
||||||
} else {
|
|
||||||
format_ns(ns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns(ns: f64) -> String {
|
|
||||||
if ns < 1_000.0 {
|
|
||||||
format!("{ns:.0}ns")
|
|
||||||
} else if ns < 1_000_000.0 {
|
|
||||||
format!("{:.1}µs", ns / 1_000.0)
|
|
||||||
} else if ns < 1_000_000_000.0 {
|
|
||||||
format!("{:.1}ms", ns / 1_000_000.0)
|
|
||||||
} else {
|
|
||||||
format!("{:.3}s", ns / 1_000_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warning_kind(kind: WarningKind) -> &'static str {
|
|
||||||
match kind {
|
|
||||||
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
|
|
||||||
WarningKind::WrongToken => "wrong_token",
|
|
||||||
WarningKind::UntrackedReply => "untracked_reply",
|
|
||||||
_ => "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_value(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.replace('\\', "\\\\")
|
|
||||||
.replace(' ', "\\s")
|
|
||||||
.replace('\n', "\\n")
|
|
||||||
.replace('\r', "\\r")
|
|
||||||
.replace('\t', "\\t")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample};
|
|
||||||
use std::{
|
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
time::{Instant, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
|
||||||
ClientTimestamp {
|
|
||||||
wall: UNIX_EPOCH + offset,
|
|
||||||
mono: Instant::now() + offset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_remote() -> SocketAddr {
|
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reply_event() -> ClientEvent {
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq: 7,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at: test_timestamp(Duration::from_secs(1)),
|
|
||||||
received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1500),
|
|
||||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
|
||||||
},
|
|
||||||
server_timing: Some(ServerTiming {
|
|
||||||
receive_wall_ns: Some(1_000),
|
|
||||||
receive_mono_ns: Some(2_000),
|
|
||||||
send_wall_ns: Some(301_000),
|
|
||||||
send_mono_ns: Some(302_000),
|
|
||||||
midpoint_wall_ns: None,
|
|
||||||
midpoint_mono_ns: None,
|
|
||||||
processing: Some(Duration::from_micros(300)),
|
|
||||||
}),
|
|
||||||
one_way: Some(OneWayDelaySample {
|
|
||||||
client_to_server: Some(SignedDuration::from_nanos(400_000)),
|
|
||||||
server_to_client: Some(SignedDuration::from_nanos(500_000)),
|
|
||||||
}),
|
|
||||||
received_stats: Some(ReceivedStatsSample {
|
|
||||||
count: Some(9),
|
|
||||||
window: Some(0x7),
|
|
||||||
}),
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rtt_us_prints_only_effective_reply_rtt() {
|
|
||||||
assert_eq!(
|
|
||||||
format_event(&reply_event(), OutputMode::RttUs),
|
|
||||||
Some("1200".to_owned())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
format_event(
|
|
||||||
&ClientEvent::Warning {
|
|
||||||
kind: WarningKind::WrongToken,
|
|
||||||
message: "bad".to_owned(),
|
|
||||||
at: ClientTimestamp::now()
|
|
||||||
},
|
|
||||||
OutputMode::RttUs
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn machine_prints_stable_key_value_fields() {
|
|
||||||
let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
|
|
||||||
assert!(line.starts_with("event=echo_reply "));
|
|
||||||
assert!(line.contains("seq=7"));
|
|
||||||
assert!(line.contains("remote=127.0.0.1:2112"));
|
|
||||||
assert!(line.contains("client_send_wall_ns=1000000000"));
|
|
||||||
assert!(line.contains("client_receive_wall_ns=1001500000"));
|
|
||||||
assert!(line.contains("raw_rtt_us=1500"));
|
|
||||||
assert!(line.contains("adjusted_rtt_us=1200"));
|
|
||||||
assert!(line.contains("effective_rtt_us=1200"));
|
|
||||||
assert!(line.contains("server_processing_us=300"));
|
|
||||||
assert!(line.contains("server_received_count=9"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn simple_and_human_use_readable_reply_lines() {
|
|
||||||
assert_eq!(
|
|
||||||
format_event(&reply_event(), OutputMode::Simple),
|
|
||||||
Some(
|
|
||||||
"reply seq=7 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
|
|
||||||
.to_owned()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
let human = format_event(&reply_event(), OutputMode::Human).unwrap();
|
|
||||||
assert!(human.starts_with("seq=7 rtt=1.2ms"));
|
|
||||||
assert!(human.contains("rd=500.0µs"));
|
|
||||||
assert!(human.contains("sd=400.0µs"));
|
|
||||||
assert!(human.contains("ipdv=n/a"));
|
|
||||||
assert!(human.contains("proc=300.0µs"));
|
|
||||||
assert!(!human.contains("rtt_us="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_sent_is_not_formatted_for_stream_outputs() {
|
|
||||||
let ts = test_timestamp(Duration::from_secs(1));
|
|
||||||
let event = ClientEvent::EchoSent {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
scheduled_at: ts.mono,
|
|
||||||
sent_at: ts,
|
|
||||||
bytes: 64,
|
|
||||||
send_call: Duration::from_micros(10),
|
|
||||||
timer_error: Duration::ZERO,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(format_event(&event, OutputMode::RttUs).is_none());
|
|
||||||
assert!(format_event(&event, OutputMode::Human).is_none());
|
|
||||||
assert!(format_event(&event, OutputMode::Machine).is_none());
|
|
||||||
assert!(format_event(&event, OutputMode::Simple).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
use std::{
|
|
||||||
io::{self, Write},
|
|
||||||
sync::atomic::AtomicBool,
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_client::ClientEvent;
|
|
||||||
#[cfg(all(test, feature = "stats"))]
|
|
||||||
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
args::{ClientArgs, OutputMode},
|
|
||||||
output::{format_event, format_human_event_with_options, HumanOutputOptions},
|
|
||||||
};
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
use crate::shared::client::expected_probe_count;
|
|
||||||
use crate::shared::client::{
|
|
||||||
is_shutdown_requested, session::should_print_final_summary, ClientSession,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const MIB: u64 = 1024 * 1024;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const GIB: u64 = 1024 * MIB;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
|
||||||
|
|
||||||
pub fn run_stream(
|
|
||||||
args: ClientArgs,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mode = args.output;
|
|
||||||
let continuous = args.is_continuous();
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
if let Some(warning) = finite_stats_memory_warning(&args) {
|
|
||||||
eprintln!("{warning}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
mode,
|
|
||||||
human_options: HumanOutputOptions {
|
|
||||||
verbose: args.verbose,
|
|
||||||
},
|
|
||||||
print_final_summary: false,
|
|
||||||
show_running_only_summary_note: false,
|
|
||||||
out: &mut stdout,
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.open()?;
|
|
||||||
stream_output.print_events(&events)?;
|
|
||||||
|
|
||||||
let mut interrupted = false;
|
|
||||||
while session.should_continue(shutdown_requested) {
|
|
||||||
let events = session.step(shutdown_requested)?;
|
|
||||||
stream_output.print_events(&events)?;
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
interrupted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.sleep_until_next_send();
|
|
||||||
}
|
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
||||||
|
|
||||||
if interrupted {
|
|
||||||
eprintln!("interrupted, closing session...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.should_drain_final(interrupted) {
|
|
||||||
session.drain_final(|events| {
|
|
||||||
let _ = stream_output.print_events(events);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.poll_timeouts()?;
|
|
||||||
stream_output.print_events(&events)?;
|
|
||||||
|
|
||||||
let events = session.close()?;
|
|
||||||
stream_output.print_events(&events)?;
|
|
||||||
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
|
||||||
stream_output.print_final_summary = print_final_summary;
|
|
||||||
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
|
||||||
stream_output.print_summary()?;
|
|
||||||
stream_output.out.flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamOutput<'a, W: Write> {
|
|
||||||
mode: OutputMode,
|
|
||||||
human_options: HumanOutputOptions,
|
|
||||||
print_final_summary: bool,
|
|
||||||
show_running_only_summary_note: bool,
|
|
||||||
out: &'a mut W,
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
stats: &'a mut StatsCollector,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<W: Write> StreamOutput<'_, W> {
|
|
||||||
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
|
|
||||||
for event in events {
|
|
||||||
self.print_event(event)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
let stats_update = self.stats.process(event);
|
|
||||||
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
let stats_update = crate::cmd::client::HumanEventStats::default();
|
|
||||||
|
|
||||||
let line =
|
|
||||||
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
let human_stats = stats_update.into();
|
|
||||||
#[cfg(not(feature = "stats"))]
|
|
||||||
let human_stats = stats_update;
|
|
||||||
|
|
||||||
Some(format_human_event_with_options(
|
|
||||||
event,
|
|
||||||
Some(human_stats),
|
|
||||||
self.human_options,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
format_event(event, self.mode)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(line) = line {
|
|
||||||
writeln!(self.out, "{line}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_summary(&mut self) -> io::Result<()> {
|
|
||||||
if !self.print_final_summary || !self.mode.prints_summary() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
{
|
|
||||||
write!(
|
|
||||||
self.out,
|
|
||||||
"{}",
|
|
||||||
crate::cmd::client::summary::format_summary_with_options(
|
|
||||||
&self.stats.snapshot(),
|
|
||||||
crate::cmd::client::summary::SummaryFormatOptions {
|
|
||||||
verbose: self.human_options.verbose,
|
|
||||||
show_running_only_note: self.show_running_only_summary_note,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
fn stats_config(continuous: bool) -> StatsConfig {
|
|
||||||
if continuous {
|
|
||||||
StatsConfig::continuous()
|
|
||||||
} else {
|
|
||||||
StatsConfig::finite()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
|
|
||||||
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
|
|
||||||
if args.is_continuous() || args.duration.is_zero() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected_probes = expected_probe_count(args.duration, args.interval);
|
|
||||||
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
|
|
||||||
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let formatted = format_bytes_for_warning(estimated_bytes);
|
|
||||||
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
|
|
||||||
"this may be unsuitable on memory-constrained systems"
|
|
||||||
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
|
|
||||||
"consider shortening the run, increasing the interval, or using continuous mode"
|
|
||||||
} else {
|
|
||||||
"use continuous mode for bounded-memory long-running tests"
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(format!(
|
|
||||||
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
|
||||||
fn format_bytes_for_warning(bytes: u64) -> String {
|
|
||||||
if bytes >= GIB {
|
|
||||||
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
|
|
||||||
} else {
|
|
||||||
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "stats"))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::{
|
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
time::{Duration, Instant, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
|
||||||
ClientTimestamp {
|
|
||||||
wall: UNIX_EPOCH + offset,
|
|
||||||
mono: Instant::now() + offset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_remote() -> SocketAddr {
|
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
|
|
||||||
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
wall: sent_at.wall + Duration::from_micros(rtt_us),
|
|
||||||
mono: sent_at.mono + Duration::from_micros(rtt_us),
|
|
||||||
};
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(rtt_us),
|
|
||||||
adjusted: None,
|
|
||||||
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
|
|
||||||
},
|
|
||||||
server_timing: None,
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cli_args(args: &[&str]) -> ClientArgs {
|
|
||||||
let mut argv = vec!["irtt-rs"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_helper_streams_and_collects_events() {
|
|
||||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
|
||||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
|
|
||||||
let events = [
|
|
||||||
ClientEvent::EchoSent {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
scheduled_at: sent_at.mono,
|
|
||||||
sent_at,
|
|
||||||
bytes: 64,
|
|
||||||
send_call: Duration::from_micros(10),
|
|
||||||
timer_error: Duration::ZERO,
|
|
||||||
},
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1200),
|
|
||||||
adjusted: None,
|
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
|
||||||
},
|
|
||||||
server_timing: None,
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut stats = StatsCollector::new(StatsConfig::finite());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
{
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
mode: OutputMode::RttUs,
|
|
||||||
human_options: HumanOutputOptions::default(),
|
|
||||||
print_final_summary: true,
|
|
||||||
show_running_only_summary_note: false,
|
|
||||||
out: &mut out,
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
stream_output.print_events(&events).unwrap();
|
|
||||||
stream_output.print_summary().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let rendered = String::from_utf8(out).unwrap();
|
|
||||||
let summary = stats.snapshot();
|
|
||||||
assert_eq!(rendered, "1200\n");
|
|
||||||
assert_eq!(summary.packets.packets_sent, 1);
|
|
||||||
assert_eq!(summary.packets.unique_replies, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
|
|
||||||
let mut stats = StatsCollector::new(StatsConfig::continuous());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
{
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
mode: OutputMode::Human,
|
|
||||||
human_options: HumanOutputOptions::default(),
|
|
||||||
print_final_summary: true,
|
|
||||||
show_running_only_summary_note: true,
|
|
||||||
out: &mut out,
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
stream_output.print_event(&reply_event(1, 1200)).unwrap();
|
|
||||||
stream_output.print_summary().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let rendered = String::from_utf8(out).unwrap();
|
|
||||||
assert!(rendered.contains("irtt-rs summary"));
|
|
||||||
assert!(rendered.contains("medians unavailable"));
|
|
||||||
assert!(rendered.contains("continuous mode"));
|
|
||||||
assert!(rendered.contains("packets:"));
|
|
||||||
assert!(rendered.contains("received=1"));
|
|
||||||
|
|
||||||
let mut stats = StatsCollector::new(StatsConfig::continuous());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
{
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
mode: OutputMode::Human,
|
|
||||||
human_options: HumanOutputOptions::default(),
|
|
||||||
print_final_summary: false,
|
|
||||||
show_running_only_summary_note: true,
|
|
||||||
out: &mut out,
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
stream_output.print_summary().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(out.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn human_output_prints_ipdv_from_stats_update() {
|
|
||||||
let events = [reply_event(0, 1200), reply_event(1, 1250)];
|
|
||||||
let mut stats = StatsCollector::new(StatsConfig::finite());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
{
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
mode: OutputMode::Human,
|
|
||||||
human_options: HumanOutputOptions::default(),
|
|
||||||
print_final_summary: false,
|
|
||||||
show_running_only_summary_note: false,
|
|
||||||
out: &mut out,
|
|
||||||
stats: &mut stats,
|
|
||||||
};
|
|
||||||
stream_output.print_events(&events).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let rendered = String::from_utf8(out).unwrap();
|
|
||||||
let mut lines = rendered.lines();
|
|
||||||
assert!(lines.next().unwrap().contains("ipdv=n/a"));
|
|
||||||
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn continuous_mode_uses_continuous_stats_config() {
|
|
||||||
let mut collector = StatsCollector::new(stats_config(true));
|
|
||||||
for seq in 0..5000_u32 {
|
|
||||||
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
|
|
||||||
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
|
|
||||||
collector.process(&ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1),
|
|
||||||
adjusted: None,
|
|
||||||
effective: SignedDuration::from_nanos(1_000),
|
|
||||||
},
|
|
||||||
server_timing: None,
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
|
|
||||||
for args in [
|
|
||||||
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
|
|
||||||
cli_args(&[
|
|
||||||
"--duration",
|
|
||||||
"1000ms",
|
|
||||||
"--interval",
|
|
||||||
"1ms",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
]),
|
|
||||||
] {
|
|
||||||
assert_eq!(finite_stats_memory_warning(&args), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (threshold, size, guidance) in [
|
|
||||||
(
|
|
||||||
FINITE_STATS_MEMORY_WARNING_BYTES,
|
|
||||||
"about 128 MiB",
|
|
||||||
"bounded-memory long-running tests",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
|
|
||||||
"about 512 MiB",
|
|
||||||
"shortening the run",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
|
|
||||||
"about 1 GiB",
|
|
||||||
"memory-constrained systems",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
|
|
||||||
let args = cli_args(&[
|
|
||||||
"--duration",
|
|
||||||
&format!("{expected_probes}ms"),
|
|
||||||
"--interval",
|
|
||||||
"1ms",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
]);
|
|
||||||
|
|
||||||
let warning = finite_stats_memory_warning(&args).unwrap();
|
|
||||||
assert!(warning.contains(size));
|
|
||||||
assert!(warning.contains(guidance));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_probe_count_rounds_up() {
|
|
||||||
assert_eq!(
|
|
||||||
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn estimate_finite_stats_memory_bytes_saturates() {
|
|
||||||
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#[cfg(feature = "client")]
|
|
||||||
pub mod client;
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
pub mod tui;
|
|
||||||
|
|
||||||
// Future command applets, such as a server applet, belong here behind their
|
|
||||||
// own feature gate.
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use irtt_client::ClientConfig;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::shared::client::TimestampArg;
|
|
||||||
use crate::shared::client::{parse_test_duration, CommonClientArgs};
|
|
||||||
|
|
||||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
|
||||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
|
||||||
pub struct TuiArgs {
|
|
||||||
/// Server address or host, with optional port.
|
|
||||||
pub server: String,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
default_value = "0",
|
|
||||||
value_parser = parse_test_duration,
|
|
||||||
help = "Test duration; use 0 for continuous mode",
|
|
||||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
|
||||||
)]
|
|
||||||
pub duration: Duration,
|
|
||||||
|
|
||||||
#[command(flatten)]
|
|
||||||
pub common: CommonClientArgs,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiArgs {
|
|
||||||
pub fn to_client_config(&self) -> ClientConfig {
|
|
||||||
self.common.to_client_config(&self.server, self.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
|
||||||
self.duration == Duration::ZERO
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
|
||||||
self.common.tstamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for TuiArgs {
|
|
||||||
type Target = CommonClientArgs;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.common
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::cmd::client::ClientArgs;
|
|
||||||
use clap::{CommandFactory, Parser};
|
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-tui"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
TuiArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-cli"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
ClientArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
|
||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
|
||||||
assert_eq!(args.server, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
|
||||||
assert!(args.is_continuous());
|
|
||||||
assert_eq!(args.to_client_config().duration, None);
|
|
||||||
|
|
||||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
|
||||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
|
||||||
assert_eq!(
|
|
||||||
finite.to_client_config().duration,
|
|
||||||
Some(Duration::from_secs(30))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
|
||||||
let help = TuiArgs::command().render_help().to_string();
|
|
||||||
assert!(!help.contains("--output"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shared_client_options_match_between_stream_and_tui_parsers() {
|
|
||||||
let shared = [
|
|
||||||
"--duration",
|
|
||||||
"30s",
|
|
||||||
"--interval",
|
|
||||||
"250ms",
|
|
||||||
"--length",
|
|
||||||
"128",
|
|
||||||
"--hmac",
|
|
||||||
"secret",
|
|
||||||
"--clock",
|
|
||||||
"monotonic",
|
|
||||||
"--tstamp",
|
|
||||||
"receive",
|
|
||||||
"--stats",
|
|
||||||
"count",
|
|
||||||
"--sfill",
|
|
||||||
"abc",
|
|
||||||
"--dscp",
|
|
||||||
"46",
|
|
||||||
"--ttl",
|
|
||||||
"64",
|
|
||||||
"--loose",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
];
|
|
||||||
let client = parse_client(&shared).unwrap().to_client_config();
|
|
||||||
let tui = parse(&shared).unwrap().to_client_config();
|
|
||||||
|
|
||||||
assert_eq!(client.server_addr, tui.server_addr);
|
|
||||||
assert_eq!(client.duration, tui.duration);
|
|
||||||
assert_eq!(client.interval, tui.interval);
|
|
||||||
assert_eq!(client.length, tui.length);
|
|
||||||
assert_eq!(client.received_stats, tui.received_stats);
|
|
||||||
assert_eq!(client.stamp_at, tui.stamp_at);
|
|
||||||
assert_eq!(client.clock, tui.clock);
|
|
||||||
assert_eq!(client.dscp, tui.dscp);
|
|
||||||
assert_eq!(client.hmac_key, tui.hmac_key);
|
|
||||||
assert_eq!(client.server_fill, tui.server_fill);
|
|
||||||
assert_eq!(client.negotiation_policy, tui.negotiation_policy);
|
|
||||||
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod args;
|
|
||||||
pub mod run;
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
pub use args::TuiArgs;
|
|
||||||
pub use run::run_tui;
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
use std::{
|
|
||||||
io,
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
|
||||||
use irtt_stats::StatsCollector;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
cmd::tui::args::TuiArgs,
|
|
||||||
shared::client::{is_shutdown_requested, ClientSession},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
|
||||||
|
|
||||||
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
|
|
||||||
if continuous {
|
|
||||||
irtt_stats::StatsConfig::continuous()
|
|
||||||
} else {
|
|
||||||
irtt_stats::StatsConfig::finite()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
|
||||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
||||||
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
|
||||||
|
|
||||||
pub fn run_tui(
|
|
||||||
args: TuiArgs,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let continuous = args.is_continuous();
|
|
||||||
let mut terminal = TuiTerminal::enter()?;
|
|
||||||
let mut state = TuiState::new(TuiConfig::from_args(&args));
|
|
||||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
|
||||||
let mut next_render = Instant::now();
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
state.set_status(TuiStatus::Opening);
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
|
|
||||||
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
|
|
||||||
Ok(session) => session,
|
|
||||||
Err(err) => {
|
|
||||||
state.set_error(err.to_string());
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
return Err(Box::new(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.open()?;
|
|
||||||
state.process_events(&events, &mut stats);
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
|
|
||||||
let mut interrupted = false;
|
|
||||||
// Keep this in lockstep with run_stream: send due probes, drain available
|
|
||||||
// replies, poll timeouts, sleep toward the next absolute send deadline,
|
|
||||||
// then perform the same final drain, timeout poll, and close sequence.
|
|
||||||
while session.should_continue(shutdown_requested) {
|
|
||||||
if handle_input(&mut state, shutdown_requested)? {
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
}
|
|
||||||
if state.quit_requested {
|
|
||||||
interrupted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.step(shutdown_requested)?;
|
|
||||||
state.process_events(&events, &mut stats);
|
|
||||||
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
interrupted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
|
|
||||||
wait_for_tui_activity(
|
|
||||||
session.next_send_deadline(),
|
|
||||||
&mut next_render,
|
|
||||||
&mut state,
|
|
||||||
&stats,
|
|
||||||
&mut terminal,
|
|
||||||
shutdown_requested,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
||||||
|
|
||||||
if interrupted {
|
|
||||||
state.set_status(TuiStatus::Interrupted);
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.should_drain_final(interrupted) {
|
|
||||||
state.set_status(TuiStatus::Draining);
|
|
||||||
let mut drain_render = Instant::now();
|
|
||||||
session.drain_final(|events| {
|
|
||||||
state.process_events(events, &mut stats);
|
|
||||||
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false);
|
|
||||||
})?;
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = session.poll_timeouts()?;
|
|
||||||
state.process_events(&events, &mut stats);
|
|
||||||
|
|
||||||
state.set_status(TuiStatus::Closing);
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
|
|
||||||
let events = session.close()?;
|
|
||||||
state.process_events(&events, &mut stats);
|
|
||||||
state.set_status(TuiStatus::Complete);
|
|
||||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
|
||||||
let mut force_render = false;
|
|
||||||
while event::poll(Duration::ZERO)? {
|
|
||||||
let Event::Key(key) = event::read()? else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if key.kind == KeyEventKind::Release {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
state.quit_requested = true;
|
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
state.quit_requested = true;
|
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') => {
|
|
||||||
state.clear_visible_history();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('p') => {
|
|
||||||
state.toggle_pause();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('g') => {
|
|
||||||
state.cycle_graph_mode();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('f') => {
|
|
||||||
state.toggle_full_graph();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(force_render)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_if_due(
|
|
||||||
terminal: &mut TuiTerminal,
|
|
||||||
state: &TuiState,
|
|
||||||
stats: &StatsCollector,
|
|
||||||
next_render: &mut Instant,
|
|
||||||
force: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let now = Instant::now();
|
|
||||||
if !should_render(now, *next_render, state.paused, force) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
terminal.draw(state, &stats.snapshot())?;
|
|
||||||
*next_render = now + RENDER_INTERVAL;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_tui_activity(
|
|
||||||
next_send_deadline: Option<Instant>,
|
|
||||||
next_render: &mut Instant,
|
|
||||||
state: &mut TuiState,
|
|
||||||
stats: &StatsCollector,
|
|
||||||
terminal: &mut TuiTerminal,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
|
|
||||||
if wait_for.is_zero() || !event::poll(wait_for)? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if handle_input(state, shutdown_requested)? {
|
|
||||||
render_if_due(terminal, state, stats, next_render, true)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tui_wait_duration(
|
|
||||||
next_send_deadline: Option<Instant>,
|
|
||||||
next_render: Instant,
|
|
||||||
paused: bool,
|
|
||||||
) -> Duration {
|
|
||||||
let now = Instant::now();
|
|
||||||
let send_wait = next_send_deadline
|
|
||||||
.map(|deadline| deadline.saturating_duration_since(now))
|
|
||||||
.unwrap_or(IDLE_SLEEP);
|
|
||||||
let render_wait = if paused {
|
|
||||||
send_wait
|
|
||||||
} else {
|
|
||||||
next_render.saturating_duration_since(now)
|
|
||||||
};
|
|
||||||
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
+1472
-4
File diff suppressed because it is too large.
Load diff
+447
-60
@@ -1,19 +1,40 @@
|
|||||||
use std::{
|
use std::{
|
||||||
env,
|
io::{self, Write},
|
||||||
ffi::OsString,
|
|
||||||
process::ExitCode,
|
process::ExitCode,
|
||||||
sync::{atomic::AtomicBool, Arc},
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
use irtt_cli::{
|
use irtt_cli::{
|
||||||
applet::{
|
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
|
||||||
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
|
|
||||||
},
|
|
||||||
signal::install_signal_handler,
|
|
||||||
};
|
};
|
||||||
|
use irtt_client::{Client, ClientEvent, OpenOutcome, RecvBudget};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use irtt_client::ClientTimestamp;
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
use irtt_stats::{StatsCollector, StatsConfig};
|
||||||
|
|
||||||
|
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
|
||||||
|
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
|
||||||
|
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||||
|
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
match run_from_env() {
|
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,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("irtt-rs: {err}");
|
eprintln!("irtt-rs: {err}");
|
||||||
@@ -22,71 +43,437 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||||
let argv: Vec<OsString> = env::args_os().collect();
|
ctrlc::set_handler(move || {
|
||||||
let argv0 = argv
|
shutdown_requested.store(true, Ordering::Relaxed);
|
||||||
.first()
|
})
|
||||||
.and_then(|arg| arg.to_str())
|
}
|
||||||
.unwrap_or("irtt-rs");
|
|
||||||
let requested = match detect_applet_from_argv0(argv0) {
|
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Some(applet) => applet,
|
let mode = args.output;
|
||||||
None => default_requested_applet()?,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
install_signal_handler(Arc::clone(&shutdown_requested))
|
return Ok(());
|
||||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
}
|
||||||
|
|
||||||
match requested {
|
let mut client = Client::connect(args.to_client_config())?;
|
||||||
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
|
||||||
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
RequestedApplet::Server => Err("server applet is not available in this build".into()),
|
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 default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
|
fn drain_final_replies<W: Write>(
|
||||||
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
|
client: &mut Client,
|
||||||
AppletAvailability::Client => Ok(RequestedApplet::Client),
|
output: &mut EventOutput<'_, W>,
|
||||||
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
AppletAvailability::Server => Ok(RequestedApplet::Server),
|
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
|
||||||
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
struct EventOutput<'a, W: Write> {
|
||||||
fn run_client_applet(
|
mode: irtt_cli::OutputMode,
|
||||||
argv: Vec<OsString>,
|
human_options: HumanOutputOptions,
|
||||||
shutdown_requested: &AtomicBool,
|
print_finite_summary: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
out: &'a mut W,
|
||||||
use clap::Parser;
|
#[cfg(feature = "stats")]
|
||||||
|
stats: &'a mut StatsCollector,
|
||||||
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
|
|
||||||
irtt_cli::cmd::client::run_stream(args, shutdown_requested)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "client"))]
|
impl<W: Write> EventOutput<'_, W> {
|
||||||
fn run_client_applet(
|
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
|
||||||
_argv: Vec<OsString>,
|
for event in events {
|
||||||
_shutdown_requested: &AtomicBool,
|
self.print_event(event)?;
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
}
|
||||||
Err("client applet is not available; rebuild with the client feature".into())
|
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 = "tui")]
|
#[cfg(feature = "stats")]
|
||||||
fn run_tui_applet(
|
fn stats_config(continuous: bool) -> StatsConfig {
|
||||||
argv: Vec<OsString>,
|
if continuous {
|
||||||
shutdown_requested: &AtomicBool,
|
StatsConfig::continuous()
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
} else {
|
||||||
use clap::Parser;
|
StatsConfig::finite()
|
||||||
|
}
|
||||||
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
|
|
||||||
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "tui"))]
|
fn final_drain_duration(probe_timeout: Duration) -> Duration {
|
||||||
fn run_tui_applet(
|
probe_timeout.min(MAX_FINAL_DRAIN)
|
||||||
_argv: Vec<OsString>,
|
}
|
||||||
_shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
#[cfg(test)]
|
||||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
pub mod args;
|
|
||||||
pub mod config;
|
|
||||||
pub mod session;
|
|
||||||
|
|
||||||
pub use args::{
|
|
||||||
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
|
||||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
|
||||||
};
|
|
||||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
|
||||||
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
use std::{
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
|
|
||||||
|
|
||||||
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
|
|
||||||
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
|
|
||||||
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
|
||||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
|
||||||
|
|
||||||
pub struct ClientSession {
|
|
||||||
client: Client,
|
|
||||||
continuous: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientSession {
|
|
||||||
pub fn connect(
|
|
||||||
config: ClientConfig,
|
|
||||||
continuous: bool,
|
|
||||||
) -> Result<Self, irtt_client::ClientError> {
|
|
||||||
Ok(Self {
|
|
||||||
client: Client::connect(config)?,
|
|
||||||
continuous,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
Ok(vec![open_event(&self.client.open()?).clone()])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn step(
|
|
||||||
&mut self,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
|
|
||||||
events.extend(self.client.send_probe()?);
|
|
||||||
}
|
|
||||||
events.extend(self.client.recv_available(RECV_BUDGET)?);
|
|
||||||
events.extend(self.client.poll_timeouts()?);
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested)
|
|
||||||
&& (self.continuous || self.client.next_send_deadline().is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_drain_final(&self, interrupted: bool) -> bool {
|
|
||||||
should_drain_final(self.continuous, interrupted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
|
|
||||||
where
|
|
||||||
F: FnMut(&[ClientEvent]),
|
|
||||||
{
|
|
||||||
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
|
|
||||||
loop {
|
|
||||||
let mut received = false;
|
|
||||||
|
|
||||||
let events = self.client.recv_available(RECV_BUDGET)?;
|
|
||||||
received |= !events.is_empty();
|
|
||||||
on_events(&events);
|
|
||||||
|
|
||||||
let events = self.client.poll_timeouts()?;
|
|
||||||
received |= !events.is_empty();
|
|
||||||
on_events(&events);
|
|
||||||
|
|
||||||
if self.client.is_run_complete() || Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !received {
|
|
||||||
thread::sleep(IDLE_SLEEP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
self.client.poll_timeouts()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
|
|
||||||
self.client.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
|
||||||
self.client.next_send_deadline()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn probe_timeout(&self) -> Duration {
|
|
||||||
self.client.probe_timeout()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sleep_until_next_send(&self) {
|
|
||||||
let sleep_for = match self.next_send_deadline() {
|
|
||||||
Some(deadline) => deadline
|
|
||||||
.saturating_duration_since(Instant::now())
|
|
||||||
.min(MAX_SLEEP),
|
|
||||||
None => Duration::from_millis(1),
|
|
||||||
};
|
|
||||||
if !sleep_for.is_zero() {
|
|
||||||
thread::sleep(sleep_for);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
shutdown_requested.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
|
||||||
!continuous || interrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
|
||||||
!continuous || interrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
|
|
||||||
probe_timeout.min(MAX_FINAL_DRAIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
!is_shutdown_requested(shutdown_requested)
|
|
||||||
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
|
||||||
match outcome {
|
|
||||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn final_drain_uses_capped_probe_timeout() {
|
|
||||||
assert_eq!(
|
|
||||||
final_drain_duration(Duration::from_secs(4)),
|
|
||||||
Duration::from_secs(4)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
final_drain_duration(Duration::from_secs(60)),
|
|
||||||
Duration::from_secs(30)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn should_drain_final_for_finite_or_interrupted_runs() {
|
|
||||||
assert!(should_drain_final(false, false));
|
|
||||||
assert!(should_drain_final(false, true));
|
|
||||||
assert!(should_drain_final(true, true));
|
|
||||||
assert!(!should_drain_final(true, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#[cfg(feature = "client")]
|
|
||||||
pub mod client;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ 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 {
|
||||||
@@ -19,13 +18,6 @@ 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,
|
||||||
@@ -206,8 +198,10 @@ mod tests {
|
|||||||
received_at,
|
received_at,
|
||||||
rtt: RttSample {
|
rtt: RttSample {
|
||||||
raw: Duration::from_micros(1500),
|
raw: Duration::from_micros(1500),
|
||||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
adjusted: Some(Duration::from_micros(1200)),
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
effective: Duration::from_micros(1200),
|
||||||
|
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,
|
||||||
@@ -238,7 +232,6 @@ 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]
|
||||||
@@ -252,8 +245,10 @@ mod tests {
|
|||||||
received_at,
|
received_at,
|
||||||
rtt: RttSample {
|
rtt: RttSample {
|
||||||
raw: Duration::from_micros(1500),
|
raw: Duration::from_micros(1500),
|
||||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
adjusted: Some(Duration::from_micros(1200)),
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
effective: Duration::from_micros(1200),
|
||||||
|
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,
|
||||||
@@ -264,32 +259,13 @@ mod tests {
|
|||||||
|
|
||||||
let output = format_summary_with_options(
|
let output = format_summary_with_options(
|
||||||
&collector.snapshot(),
|
&collector.snapshot(),
|
||||||
SummaryFormatOptions {
|
SummaryFormatOptions { verbose: true },
|
||||||
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 {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.3.0"
|
version = "0.2.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.4", features = ["all"] }
|
socket2 = { version = "0.6.3", features = ["all"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ use crate::{
|
|||||||
const MAX_OPEN_PACKET_SIZE: usize = 512;
|
const MAX_OPEN_PACKET_SIZE: usize = 512;
|
||||||
const MIN_RECV_BUFFER_SIZE: usize = 2048;
|
const MIN_RECV_BUFFER_SIZE: usize = 2048;
|
||||||
|
|
||||||
/// Low-level synchronous IRTT client.
|
|
||||||
///
|
|
||||||
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
|
|
||||||
/// session, send probes, receive replies, poll timeouts, and close. Callers
|
|
||||||
/// that do not need to own this loop can use [`ManagedClient`](crate::ManagedClient)
|
|
||||||
/// instead.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
config: ClientConfig,
|
config: ClientConfig,
|
||||||
@@ -51,11 +45,6 @@ pub struct Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// Resolve the configured server and create a connected UDP socket.
|
|
||||||
///
|
|
||||||
/// This validates local configuration and prepares the open request, but it
|
|
||||||
/// does not contact the server. Call [`open`](Self::open) to perform the
|
|
||||||
/// IRTT open exchange.
|
|
||||||
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
|
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
|
||||||
validate_open_timeouts(&config.open_timeouts)?;
|
validate_open_timeouts(&config.open_timeouts)?;
|
||||||
if config.max_pending_probes == 0 {
|
if config.max_pending_probes == 0 {
|
||||||
@@ -84,11 +73,6 @@ impl Client {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform the IRTT open exchange.
|
|
||||||
///
|
|
||||||
/// On success, returns the negotiated open outcome and transitions the
|
|
||||||
/// client into either an open probe session or completed no-test state.
|
|
||||||
/// Open attempts use [`ClientConfig::open_timeouts`].
|
|
||||||
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||||
match self.phase {
|
match self.phase {
|
||||||
ClientPhase::Connected => {}
|
ClientPhase::Connected => {}
|
||||||
@@ -143,10 +127,6 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
|
|
||||||
///
|
|
||||||
/// The close event means the client has sent its close packet and stopped
|
|
||||||
/// tracking the session locally; it is not a server acknowledgement.
|
|
||||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
let token = match self.phase {
|
let token = match self.phase {
|
||||||
ClientPhase::Open { token } => token,
|
ClientPhase::Open { token } => token,
|
||||||
@@ -173,8 +153,6 @@ impl Client {
|
|||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the monotonic deadline for the next probe send, if another probe
|
|
||||||
/// is scheduled.
|
|
||||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||||
let session = self.session.as_ref()?;
|
let session = self.session.as_ref()?;
|
||||||
if session.sending_done {
|
if session.sending_done {
|
||||||
@@ -183,24 +161,14 @@ impl Client {
|
|||||||
Some(session.next_send_at)
|
Some(session.next_send_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the local timeout used to classify pending probes as lost.
|
|
||||||
pub fn probe_timeout(&self) -> Duration {
|
pub fn probe_timeout(&self) -> Duration {
|
||||||
self.config.probe_timeout
|
self.config.probe_timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one echo probe if the negotiated run is still active.
|
|
||||||
///
|
|
||||||
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
|
|
||||||
/// list when the run duration has elapsed and no further probe should be
|
|
||||||
/// sent.
|
|
||||||
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.send_probe_inner(None)
|
self.send_probe_inner(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive and classify at most one datagram from the socket.
|
|
||||||
///
|
|
||||||
/// Returns an empty event list when the socket read would block or times
|
|
||||||
/// out. Malformed or unrelated datagrams are reported as warning events.
|
|
||||||
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.recv_once_inner(None)
|
self.recv_once_inner(None)
|
||||||
}
|
}
|
||||||
@@ -347,8 +315,6 @@ impl Client {
|
|||||||
self.process_echo_reply(reply, packet_len, now, datagram.meta)
|
self.process_echo_reply(reply, packet_len, now, datagram.meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive and classify datagrams until a receive produces no events or the
|
|
||||||
/// receive budget is exhausted.
|
|
||||||
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
let mut all_events = Vec::new();
|
let mut all_events = Vec::new();
|
||||||
for _ in 0..budget.max_packets {
|
for _ in 0..budget.max_packets {
|
||||||
@@ -361,6 +327,12 @@ impl Client {
|
|||||||
Ok(all_events)
|
Ok(all_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn recv_buffer_size(&self) -> usize {
|
||||||
|
recv_buffer_size(self.config.hmac_key.is_some(), self.negotiated.as_ref())
|
||||||
|
.expect("test params must have a non-negative packet length")
|
||||||
|
}
|
||||||
|
|
||||||
/// Polls for probes that have timed out as of the current monotonic time.
|
/// Polls for probes that have timed out as of the current monotonic time.
|
||||||
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.poll_timeouts_at(Instant::now())
|
self.poll_timeouts_at(Instant::now())
|
||||||
@@ -400,11 +372,6 @@ impl Client {
|
|||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether the current run has completed.
|
|
||||||
///
|
|
||||||
/// A normal run is complete once no more probes will be sent and all
|
|
||||||
/// pending probes have either replied or timed out. No-test and closed
|
|
||||||
/// sessions are also considered complete.
|
|
||||||
pub fn is_run_complete(&self) -> bool {
|
pub fn is_run_complete(&self) -> bool {
|
||||||
let Some(session) = self.session.as_ref() else {
|
let Some(session) = self.session.as_ref() else {
|
||||||
return matches!(
|
return matches!(
|
||||||
@@ -427,6 +394,24 @@ impl Client {
|
|||||||
.map_or(0, |session| session.packets_sent)
|
.map_or(0, |session| session.packets_sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn process_received_packet(
|
||||||
|
&mut self,
|
||||||
|
packet: &[u8],
|
||||||
|
now: ClientTimestamp,
|
||||||
|
meta: ReceiveMeta,
|
||||||
|
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
|
let packet_len = packet.len();
|
||||||
|
let Some(reply) = self.decode_received_packet(packet) else {
|
||||||
|
return Ok(vec![ClientEvent::Warning {
|
||||||
|
kind: WarningKind::MalformedOrUnrelatedPacket,
|
||||||
|
message: "dropped malformed or unrelated packet".to_owned(),
|
||||||
|
at: now,
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
self.process_echo_reply(reply, packet_len, now, meta)
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
|
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
|
||||||
let negotiated = self
|
let negotiated = self
|
||||||
.negotiated
|
.negotiated
|
||||||
@@ -472,7 +457,7 @@ impl Client {
|
|||||||
.is_some_and(|h| sequence_is_before(wire_seq, h));
|
.is_some_and(|h| sequence_is_before(wire_seq, h));
|
||||||
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
||||||
|
|
||||||
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
update_highest_received(session, wire_seq);
|
||||||
session.completed.insert(wire_seq);
|
session.completed.insert(wire_seq);
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
@@ -509,7 +494,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
Ok(events)
|
Ok(events)
|
||||||
} else if session.completed.contains(wire_seq) {
|
} else if session.completed.contains(wire_seq) {
|
||||||
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
update_highest_received(session, wire_seq);
|
||||||
Ok(vec![ClientEvent::DuplicateReply {
|
Ok(vec![ClientEvent::DuplicateReply {
|
||||||
seq: wire_seq,
|
seq: wire_seq,
|
||||||
remote: self.remote,
|
remote: self.remote,
|
||||||
@@ -522,7 +507,7 @@ impl Client {
|
|||||||
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
|
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
|
||||||
let received_stats = build_received_stats(&reply);
|
let received_stats = build_received_stats(&reply);
|
||||||
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
|
||||||
update_highest_received(&mut session.highest_received_seq, wire_seq);
|
update_highest_received(session, wire_seq);
|
||||||
session.completed.insert(wire_seq);
|
session.completed.insert(wire_seq);
|
||||||
|
|
||||||
let mut events = vec![ClientEvent::LateReply {
|
let mut events = vec![ClientEvent::LateReply {
|
||||||
@@ -703,10 +688,14 @@ impl Client {
|
|||||||
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
|
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.send_probe_inner(Some(ts))
|
self.send_probe_inner(Some(ts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn recv_once_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
|
self.recv_once_inner(Some(ts))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
|
fn update_highest_received(session: &mut ActiveSession, wire_seq: u32) {
|
||||||
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
|
session.highest_received_seq = Some(session.highest_received_seq.map_or(wire_seq, |h| {
|
||||||
if sequence_is_after(wire_seq, h) {
|
if sequence_is_after(wire_seq, h) {
|
||||||
wire_seq
|
wire_seq
|
||||||
} else {
|
} else {
|
||||||
@@ -766,14 +755,22 @@ fn compute_rtt(
|
|||||||
|
|
||||||
let server_processing = compute_server_processing(ts);
|
let server_processing = compute_server_processing(ts);
|
||||||
|
|
||||||
let adjusted = server_processing
|
let adjusted = server_processing.and_then(|sp| raw.checked_sub(sp));
|
||||||
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
|
|
||||||
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
|
let effective = adjusted.unwrap_or(raw);
|
||||||
|
let adjusted_signed = server_processing.map(|sp| SignedDuration {
|
||||||
|
ns: duration_ns_i128(raw) - duration_ns_i128(sp),
|
||||||
|
});
|
||||||
|
let effective_signed = adjusted_signed.unwrap_or(SignedDuration {
|
||||||
|
ns: duration_ns_i128(raw),
|
||||||
|
});
|
||||||
|
|
||||||
RttSample {
|
RttSample {
|
||||||
raw,
|
raw,
|
||||||
adjusted,
|
adjusted,
|
||||||
effective,
|
effective,
|
||||||
|
adjusted_signed,
|
||||||
|
effective_signed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,11 +825,11 @@ fn compute_one_way(
|
|||||||
let c2s = server_recv_wall
|
let c2s = server_recv_wall
|
||||||
.zip(client_send_ns)
|
.zip(client_send_ns)
|
||||||
.and_then(|(srv, cli)| srv.checked_sub(cli))
|
.and_then(|(srv, cli)| srv.checked_sub(cli))
|
||||||
.map(|d| SignedDuration::from_nanos(i128::from(d)));
|
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
|
||||||
let s2c = client_recv_ns
|
let s2c = client_recv_ns
|
||||||
.zip(server_send_wall)
|
.zip(server_send_wall)
|
||||||
.and_then(|(cli, srv)| cli.checked_sub(srv))
|
.and_then(|(cli, srv)| cli.checked_sub(srv))
|
||||||
.map(|d| SignedDuration::from_nanos(i128::from(d)));
|
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
|
||||||
|
|
||||||
if c2s.is_none() && s2c.is_none() {
|
if c2s.is_none() && s2c.is_none() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ 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_boundary_values() {
|
fn params_from_config_accepts_udp_payload_length_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,12 +62,18 @@ fn params_from_config_accepts_boundary_values() {
|
|||||||
i64::from(length)
|
i64::from(length)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_from_config_rejects_oversized_udp_payload_length() {
|
||||||
let config = ClientConfig {
|
let config = ClientConfig {
|
||||||
dscp: 63,
|
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||||
..ClientConfig::default()
|
..ClientConfig::default()
|
||||||
};
|
};
|
||||||
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
assert!(matches!(
|
||||||
|
params_from_config(&config),
|
||||||
|
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -80,85 +86,97 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_from_config_rejects_invalid_values() {
|
fn params_from_config_rejects_zero_finite_duration() {
|
||||||
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::ZERO),
|
||||||
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 must be greater than zero; use None for continuous mode"
|
||||||
},
|
));
|
||||||
"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",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (name, config, expected_reason) in cases {
|
#[test]
|
||||||
assert!(
|
fn params_from_config_rejects_zero_interval() {
|
||||||
matches!(
|
let config = ClientConfig {
|
||||||
params_from_config(&config),
|
interval: Duration::ZERO,
|
||||||
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
..ClientConfig::default()
|
||||||
),
|
};
|
||||||
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
assert!(matches!(
|
||||||
);
|
params_from_config(&config),
|
||||||
}
|
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
|
||||||
|
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||||
|
let config = ClientConfig {
|
||||||
|
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
|
||||||
|
..ClientConfig::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
params_from_config(&config),
|
||||||
|
Err(ClientError::InvalidConfig { reason })
|
||||||
|
if reason == "duration is too large to encode as nanoseconds"
|
||||||
|
));
|
||||||
|
|
||||||
|
let config = ClientConfig {
|
||||||
|
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
|
||||||
|
..ClientConfig::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
params_from_config(&config),
|
||||||
|
Err(ClientError::InvalidConfig { reason })
|
||||||
|
if reason == "interval is too large to encode as nanoseconds"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_from_config_accepts_max_dscp_codepoint() {
|
||||||
|
let config = ClientConfig {
|
||||||
|
dscp: 63,
|
||||||
|
..ClientConfig::default()
|
||||||
|
};
|
||||||
|
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_from_config_rejects_invalid_dscp_codepoint() {
|
||||||
|
let config = ClientConfig {
|
||||||
|
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,5 +1,20 @@
|
|||||||
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,5 +1,16 @@
|
|||||||
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)));
|
||||||
@@ -9,6 +20,7 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +69,27 @@ 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
@@ -1,54 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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 { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#[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();
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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,4 +1,10 @@
|
|||||||
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() {
|
||||||
@@ -18,10 +24,21 @@ 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 highest = Some(u32::MAX);
|
let mut session = ActiveSession {
|
||||||
|
next_wire_seq: 0,
|
||||||
|
highest_received_seq: Some(u32::MAX),
|
||||||
|
packets_sent: 0,
|
||||||
|
start_mono: Instant::now(),
|
||||||
|
end_mono: None,
|
||||||
|
next_send_at: Instant::now(),
|
||||||
|
pending: PendingMap::new(8),
|
||||||
|
timed_out: TimedOutMap::new(8),
|
||||||
|
completed: CompletedSet::new(8),
|
||||||
|
sending_done: false,
|
||||||
|
};
|
||||||
|
|
||||||
update_highest_received(&mut highest, 0);
|
update_highest_received(&mut session, 0);
|
||||||
assert_eq!(highest, Some(0));
|
assert_eq!(session.highest_received_seq, Some(0));
|
||||||
update_highest_received(&mut highest, u32::MAX);
|
update_highest_received(&mut session, u32::MAX);
|
||||||
assert_eq!(highest, Some(0));
|
assert_eq!(session.highest_received_seq, Some(0));
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,12 @@
|
|||||||
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] = [
|
||||||
@@ -38,83 +19,23 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,65 +62,29 @@ 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,176 +2,66 @@ 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,
|
||||||
}
|
}
|
||||||
+23
-229
@@ -5,360 +5,139 @@ 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,
|
||||||
/// Signed RTT adjusted for server processing when server processing is available.
|
pub adjusted: Option<Duration>,
|
||||||
pub adjusted: Option<SignedDuration>,
|
pub effective: Duration,
|
||||||
/// Signed effective RTT used by stats and CLI reporting.
|
pub adjusted_signed: Option<SignedDuration>,
|
||||||
///
|
pub effective_signed: SignedDuration,
|
||||||
/// This is `adjusted` when server processing is available, otherwise `raw`
|
|
||||||
/// converted to a signed duration.
|
|
||||||
pub effective: SignedDuration,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signed nanosecond-backed duration used for derived timing values.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
///
|
|
||||||
/// 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 {
|
||||||
ns: i128,
|
pub 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 {
|
||||||
/// Client-to-server delay.
|
pub client_to_server: Option<Duration>,
|
||||||
///
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,3 +152,18 @@ 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,50 +1,3 @@
|
|||||||
//! 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)
|
||||||
|
|||||||
@@ -3,31 +3,20 @@ 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)
|
||||||
@@ -38,6 +27,11 @@ 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();
|
||||||
|
|||||||
@@ -8,17 +8,9 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +37,6 @@ pub enum SubscriberOverflow {
|
|||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish/subscribe fan-out for [`ClientEvent`] values.
|
|
||||||
///
|
|
||||||
/// `EventHub` is used by managed sessions internally and is also exported for
|
|
||||||
/// callers that want the same bounded subscription behavior around their own
|
|
||||||
/// event producer. Publishing clones each event once per subscriber.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EventHub {
|
pub struct EventHub {
|
||||||
inner: Arc<HubInner>,
|
inner: Arc<HubInner>,
|
||||||
@@ -88,7 +75,6 @@ struct SubscriberState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventHub {
|
impl EventHub {
|
||||||
/// Create an empty event hub with no subscribers.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(HubInner {
|
inner: Arc::new(HubInner {
|
||||||
@@ -98,9 +84,6 @@ 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,
|
||||||
@@ -133,11 +116,6 @@ impl EventHub {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to all currently connected subscribers.
|
|
||||||
///
|
|
||||||
/// Slow subscribers are handled according to their
|
|
||||||
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
|
|
||||||
/// subscriber to consume queued events.
|
|
||||||
pub fn publish(&self, event: ClientEvent) {
|
pub fn publish(&self, event: ClientEvent) {
|
||||||
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
|
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
|
||||||
.inner
|
.inner
|
||||||
@@ -171,8 +149,6 @@ impl EventHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect all subscribers after leaving their already queued events
|
|
||||||
/// available to drain.
|
|
||||||
pub fn disconnect_all(&self) {
|
pub fn disconnect_all(&self) {
|
||||||
let subscribers: Vec<Arc<SubscriberInner>> = self
|
let subscribers: Vec<Arc<SubscriberInner>> = self
|
||||||
.inner
|
.inner
|
||||||
@@ -314,16 +290,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_next(
|
#[test]
|
||||||
sub: &EventSubscription,
|
fn publishes_to_one_subscriber() {
|
||||||
expected: Result<Option<&str>, EventSubscriptionError>,
|
let hub = EventHub::new();
|
||||||
) {
|
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||||
match (sub.try_recv(), expected) {
|
|
||||||
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
|
hub.publish(event(1));
|
||||||
(Ok(None), Ok(None)) => {}
|
|
||||||
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
|
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||||
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -339,40 +313,56 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_subscriber_overflow_policy_is_applied() {
|
fn drop_newest_keeps_existing_events() {
|
||||||
let cases = [
|
let hub = EventHub::new();
|
||||||
(
|
let sub = hub
|
||||||
SubscriberOverflow::DropNewest,
|
.subscribe(SubscriberConfig {
|
||||||
Ok(Some("event-1")),
|
capacity: 1,
|
||||||
Ok(None),
|
overflow: SubscriberOverflow::DropNewest,
|
||||||
),
|
})
|
||||||
(
|
.unwrap();
|
||||||
SubscriberOverflow::DropOldest,
|
|
||||||
Ok(Some("event-2")),
|
|
||||||
Ok(None),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SubscriberOverflow::Disconnect,
|
|
||||||
Err(EventSubscriptionError::Disconnected),
|
|
||||||
Err(EventSubscriptionError::Disconnected),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (overflow, first_expected, second_expected) in cases {
|
hub.publish(event(1));
|
||||||
let hub = EventHub::new();
|
hub.publish(event(2));
|
||||||
let sub = hub
|
|
||||||
.subscribe(SubscriberConfig {
|
|
||||||
capacity: 1,
|
|
||||||
overflow,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
hub.publish(event(1));
|
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||||
hub.publish(event(2));
|
assert!(sub.try_recv().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
assert_next(&sub, first_expected);
|
#[test]
|
||||||
assert_next(&sub, second_expected);
|
fn drop_oldest_replaces_existing_events() {
|
||||||
}
|
let hub = EventHub::new();
|
||||||
|
let sub = hub
|
||||||
|
.subscribe(SubscriberConfig {
|
||||||
|
capacity: 1,
|
||||||
|
overflow: SubscriberOverflow::DropOldest,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
hub.publish(event(1));
|
||||||
|
hub.publish(event(2));
|
||||||
|
|
||||||
|
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
|
||||||
|
assert!(sub.try_recv().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_removes_full_subscriber() {
|
||||||
|
let hub = EventHub::new();
|
||||||
|
let sub = hub
|
||||||
|
.subscribe(SubscriberConfig {
|
||||||
|
capacity: 1,
|
||||||
|
overflow: SubscriberOverflow::Disconnect,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
hub.publish(event(1));
|
||||||
|
hub.publish(event(2));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sub.try_recv().unwrap_err(),
|
||||||
|
EventSubscriptionError::Disconnected
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ 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;
|
||||||
|
|
||||||
@@ -52,49 +46,26 @@ 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,
|
||||||
@@ -143,28 +114,14 @@ 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
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ 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,6 +193,18 @@ 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);
|
||||||
@@ -208,7 +220,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bounded_probe_tracking_evicts_oldest_entries() {
|
fn completed_set_tracks_and_evicts() {
|
||||||
let mut set = CompletedSet::new(3);
|
let mut set = CompletedSet::new(3);
|
||||||
set.insert(0);
|
set.insert(0);
|
||||||
set.insert(1);
|
set.insert(1);
|
||||||
@@ -220,7 +232,10 @@ mod tests {
|
|||||||
assert_eq!(set.set.len(), 3);
|
assert_eq!(set.set.len(), 3);
|
||||||
assert!(!set.contains(0));
|
assert!(!set.contains(0));
|
||||||
assert!(set.contains(3));
|
assert!(set.contains(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timed_out_map_tracks_and_evicts() {
|
||||||
let mut map = TimedOutMap::new(2);
|
let mut map = TimedOutMap::new(2);
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
map.insert(pending(0, now));
|
map.insert(pending(0, now));
|
||||||
|
|||||||
@@ -8,16 +8,9 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +57,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
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,27 +122,47 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
|
fn missing_client_key_against_hmac_required_server_times_out() {
|
||||||
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
|
let key = b"compat-secret".to_vec();
|
||||||
let server_key = b"compat-secret".to_vec();
|
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250));
|
||||||
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
|
let mut config = config_for_params(server.addr, &default_params());
|
||||||
let mut config = config_for_params(server.addr, &default_params());
|
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
config.hmac_key = None;
|
||||||
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,
|
hmac: false,
|
||||||
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]
|
||||||
@@ -291,15 +311,25 @@ fn backend_hmac_correct_key_succeeds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
|
fn backend_hmac_wrong_key_fails() {
|
||||||
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
|
let server_key = b"compat-secret".to_vec();
|
||||||
let server_key = b"compat-secret".to_vec();
|
let peer = BackendPeer::start_hmac_required(server_key);
|
||||||
let peer = BackendPeer::start_hmac_required(server_key);
|
let mut config = config_for_params(peer.addr(), &default_params());
|
||||||
let mut config = config_for_params(peer.addr(), &default_params());
|
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
config.hmac_key = Some(b"wrong-secret".to_vec());
|
||||||
config.hmac_key = hmac_key;
|
|
||||||
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
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,20 +1,17 @@
|
|||||||
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, SignedDuration,
|
RttSample, ServerTiming,
|
||||||
};
|
};
|
||||||
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, OneProbeRun, ServerObservation,
|
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
|
||||||
RECV_COUNT, RECV_WINDOW, TOKEN,
|
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::support::test_echo_packet_len;
|
use crate::support::test_echo_packet_len;
|
||||||
@@ -69,10 +66,7 @@ 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!(
|
assert_eq!(reply.rtt.effective, reply.rtt.raw);
|
||||||
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 => {
|
||||||
@@ -352,7 +346,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 < SignedDuration::from_duration(reply.rtt.raw));
|
assert!(adjusted < reply.rtt.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_one_way_presence(
|
fn assert_one_way_presence(
|
||||||
@@ -370,6 +364,46 @@ 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();
|
||||||
@@ -382,3 +416,117 @@ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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, NegotiationPolicy};
|
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, NegotiationPolicy};
|
||||||
use irtt_proto::{Params, TimestampFields};
|
use irtt_proto::{Params, TimestampFields};
|
||||||
|
|
||||||
use support::{
|
use support::{
|
||||||
@@ -32,34 +32,63 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
|
|||||||
// ─── Config mapping ───
|
// ─── Config mapping ───
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_none_short_and_max_map_into_open_params() {
|
fn server_fill_none_produces_no_fill_in_open_params() {
|
||||||
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
|
let params = default_params();
|
||||||
let mut params = default_params();
|
let run = run_one_probe(params, TimestampFields::default());
|
||||||
params.server_fill = fill.and_then(server_fill);
|
assert!(open_observation_params(&run).server_fill.is_none());
|
||||||
|
|
||||||
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_empty_and_oversized_values_are_rejected_at_config_boundary() {
|
fn server_fill_short_value_maps_into_open_params() {
|
||||||
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
|
let mut params = default_params();
|
||||||
let config = ClientConfig {
|
params.server_fill = server_fill("abc");
|
||||||
server_fill: Some(fill.to_owned()),
|
let run = run_one_probe(params, TimestampFields::default());
|
||||||
..ClientConfig::default()
|
assert_eq!(
|
||||||
};
|
open_observation_params(&run)
|
||||||
assert!(matches!(
|
.server_fill
|
||||||
Client::connect(config),
|
.as_ref()
|
||||||
Err(ClientError::InvalidConfig { .. })
|
.map(|f| f.value.as_str()),
|
||||||
));
|
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 ───
|
||||||
@@ -73,26 +102,53 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
|
fn server_fill_strict_rejects_removed_fill() {
|
||||||
for (requested_fill, returned_fill) in [
|
let mut requested = default_params();
|
||||||
(Some("rand"), None),
|
requested.server_fill = server_fill("rand");
|
||||||
(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 = returned_fill.and_then(server_fill);
|
returned.server_fill = None;
|
||||||
|
|
||||||
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]
|
||||||
@@ -120,6 +176,14 @@ 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,3 +275,61 @@ 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.3.0"
|
version = "0.2.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,6 +31,20 @@ 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,6 +98,16 @@ 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]);
|
||||||
@@ -109,6 +119,42 @@ 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,12 +155,6 @@ 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,
|
||||||
@@ -205,6 +199,16 @@ 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 [
|
||||||
@@ -276,6 +280,16 @@ 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,21 +1,3 @@
|
|||||||
//! 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;
|
||||||
@@ -35,7 +17,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, MAX_SERVER_FILL_BYTES};
|
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||||
|
|
||||||
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,16 +1,5 @@
|
|||||||
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,
|
||||||
@@ -32,10 +21,6 @@ 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);
|
||||||
@@ -54,10 +39,6 @@ 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;
|
||||||
@@ -78,12 +59,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -231,14 +206,6 @@ 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 {
|
||||||
@@ -258,7 +225,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn params_round_trip_negotiated_option_modes() {
|
fn params_round_trip_received_stats_modes() {
|
||||||
for received_stats in [
|
for received_stats in [
|
||||||
ReceivedStats::None,
|
ReceivedStats::None,
|
||||||
ReceivedStats::Count,
|
ReceivedStats::Count,
|
||||||
@@ -271,7 +238,10 @@ 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,
|
||||||
@@ -286,7 +256,10 @@ 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,
|
||||||
@@ -350,29 +323,6 @@ 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.3.0"
|
version = "0.2.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 { bytes, .. } => {
|
StatsEvent::DuplicateReply { .. } => {
|
||||||
self.apply_duplicate_reply(bytes);
|
self.apply_duplicate_reply();
|
||||||
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 { bytes, .. } => {
|
StatsEvent::UntrackedLate { .. } => {
|
||||||
self.apply_untracked_late(bytes);
|
self.apply_untracked_late();
|
||||||
EventStatsUpdate::default()
|
EventStatsUpdate::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +121,7 @@ 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 {
|
||||||
let count = u64::from(count);
|
self.packets.server_packets_received = Some(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);
|
||||||
@@ -186,11 +181,10 @@ impl CoreStats {
|
|||||||
updates
|
updates
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_duplicate_reply(&mut self, bytes: usize) {
|
fn apply_duplicate_reply(&mut self) {
|
||||||
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) {
|
||||||
@@ -201,11 +195,8 @@ impl CoreStats {
|
|||||||
self.events.warning_events += 1;
|
self.events.warning_events += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_untracked_late(&mut self, bytes: usize) {
|
fn apply_untracked_late(&mut self) {
|
||||||
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,8 +48,10 @@ 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(pair) = self.try_pair(seq.wrapping_add(1)) {
|
if let Some(next) = seq.checked_add(1) {
|
||||||
pairs.push(pair);
|
if let Some(pair) = self.try_pair(next) {
|
||||||
|
pairs.push(pair);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pairs
|
pairs
|
||||||
}
|
}
|
||||||
@@ -64,13 +66,15 @@ 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);
|
||||||
self.completed_pairs.remove(&seq.wrapping_add(1));
|
if let Some(next) = seq.checked_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.wrapping_sub(1);
|
let previous_seq = current_seq.checked_sub(1)?;
|
||||||
|
|
||||||
if !self.completed_pairs.insert(current_seq) {
|
if !self.completed_pairs.insert(current_seq) {
|
||||||
return None;
|
return None;
|
||||||
@@ -233,52 +237,6 @@ 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);
|
||||||
|
|||||||
+236
-16
@@ -3,7 +3,7 @@
|
|||||||
//! The crate consumes `irtt-client` events and produces cumulative or rolling
|
//! The crate consumes `irtt-client` events and produces cumulative or rolling
|
||||||
//! snapshots for reporting and integration code.
|
//! snapshots for reporting and integration code.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
//#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![warn(rustdoc::missing_crate_level_docs)]
|
#![warn(rustdoc::missing_crate_level_docs)]
|
||||||
|
|
||||||
@@ -198,9 +198,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,26 +206,19 @@ 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 observed by the local client.
|
/// Reply packets received by the local client, including duplicates and late packets.
|
||||||
///
|
|
||||||
/// 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 that can contribute timing measurements.
|
/// Unique replies received by the local client.
|
||||||
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, including untracked-late replies.
|
/// Late reply packets received by the local client.
|
||||||
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 observed by the local client.
|
/// Reply bytes received 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,
|
||||||
/// Highest cumulative server-reported packets received, when available.
|
/// 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>,
|
||||||
@@ -271,3 +261,233 @@ pub struct ServerProcessingStats {
|
|||||||
/// Time spent processing a probe at the server.
|
/// Time spent processing a probe at the server.
|
||||||
pub processing: TimeStats,
|
pub processing: TimeStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod memory_growth_tests {
|
||||||
|
use irtt_client::PacketMeta;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
alloc::{GlobalAlloc, Layout, System},
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
str::FromStr,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
time::{Duration, Instant, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CountingAlloc;
|
||||||
|
|
||||||
|
static CURRENT_ALLOCATED: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static PEAK_ALLOCATED: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
unsafe impl GlobalAlloc for CountingAlloc {
|
||||||
|
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||||
|
let ptr = unsafe { System.alloc(layout) };
|
||||||
|
if !ptr.is_null() {
|
||||||
|
let new_current =
|
||||||
|
CURRENT_ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
|
||||||
|
|
||||||
|
let mut peak = PEAK_ALLOCATED.load(Ordering::Relaxed);
|
||||||
|
while new_current > peak {
|
||||||
|
match PEAK_ALLOCATED.compare_exchange_weak(
|
||||||
|
peak,
|
||||||
|
new_current,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
) {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(observed) => peak = observed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||||
|
unsafe { System.dealloc(ptr, layout) };
|
||||||
|
CURRENT_ALLOCATED.fetch_sub(layout.size(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn realloc(&self, ptr: *mut u8, old_layout: Layout, new_size: usize) -> *mut u8 {
|
||||||
|
let new_ptr = unsafe { System.realloc(ptr, old_layout, new_size) };
|
||||||
|
if !new_ptr.is_null() {
|
||||||
|
if new_size >= old_layout.size() {
|
||||||
|
let delta = new_size - old_layout.size();
|
||||||
|
let new_current = CURRENT_ALLOCATED.fetch_add(delta, Ordering::Relaxed) + delta;
|
||||||
|
|
||||||
|
let mut peak = PEAK_ALLOCATED.load(Ordering::Relaxed);
|
||||||
|
while new_current > peak {
|
||||||
|
match PEAK_ALLOCATED.compare_exchange_weak(
|
||||||
|
peak,
|
||||||
|
new_current,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
) {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(observed) => peak = observed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CURRENT_ALLOCATED.fetch_sub(old_layout.size() - new_size, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_ptr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOC: CountingAlloc = CountingAlloc;
|
||||||
|
|
||||||
|
fn reset_alloc_counter() {
|
||||||
|
CURRENT_ALLOCATED.store(0, Ordering::Relaxed);
|
||||||
|
PEAK_ALLOCATED.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_allocated() -> usize {
|
||||||
|
CURRENT_ALLOCATED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peak_allocated() -> usize {
|
||||||
|
PEAK_ALLOCATED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mib(bytes: usize) -> f64 {
|
||||||
|
bytes as f64 / 1024.0 / 1024.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_timestamp(mono: Instant) -> irtt_client::ClientTimestamp {
|
||||||
|
irtt_client::ClientTimestamp {
|
||||||
|
mono,
|
||||||
|
wall: SystemTime::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sent_event(seq: u32, base: Instant) -> irtt_client::ClientEvent {
|
||||||
|
let mono = base + Duration::from_millis(u64::from(seq));
|
||||||
|
|
||||||
|
irtt_client::ClientEvent::EchoSent {
|
||||||
|
seq,
|
||||||
|
sent_at: client_timestamp(mono),
|
||||||
|
bytes: 64,
|
||||||
|
remote: SocketAddr::new(IpAddr::from_str("1.1.1.1").unwrap(), 9),
|
||||||
|
scheduled_at: Instant::now(),
|
||||||
|
send_call: Duration::from_nanos(1_000),
|
||||||
|
timer_error: Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_event(seq: u32, base: Instant) -> irtt_client::ClientEvent {
|
||||||
|
let sent_mono = base + Duration::from_millis(u64::from(seq));
|
||||||
|
let received_mono = sent_mono + Duration::from_millis(10);
|
||||||
|
|
||||||
|
irtt_client::ClientEvent::EchoReply {
|
||||||
|
seq,
|
||||||
|
sent_at: client_timestamp(sent_mono),
|
||||||
|
received_at: client_timestamp(received_mono),
|
||||||
|
remote: SocketAddr::new(IpAddr::from_str("1.1.1.1").unwrap(), 9),
|
||||||
|
packet_meta: PacketMeta {
|
||||||
|
traffic_class: None,
|
||||||
|
dscp: None,
|
||||||
|
ecn: None,
|
||||||
|
kernel_rx_timestamp: None,
|
||||||
|
},
|
||||||
|
rtt: irtt_client::RttSample {
|
||||||
|
raw: Duration::from_millis(10),
|
||||||
|
adjusted: Some(Duration::from_millis(9)),
|
||||||
|
effective: Duration::from_millis(9),
|
||||||
|
adjusted_signed: Some(irtt_client::SignedDuration {
|
||||||
|
ns: 9_000_000 + i128::from(seq % 100),
|
||||||
|
}),
|
||||||
|
effective_signed: irtt_client::SignedDuration {
|
||||||
|
ns: 9_000_000 + i128::from(seq % 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server_timing: None,
|
||||||
|
one_way: None,
|
||||||
|
received_stats: Some(irtt_client::ReceivedStatsSample {
|
||||||
|
count: Some(seq + 1),
|
||||||
|
window: Some(u64::MAX),
|
||||||
|
}),
|
||||||
|
bytes: 64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_successful_probes(collector: &mut StatsCollector, count: usize) {
|
||||||
|
let base = Instant::now();
|
||||||
|
|
||||||
|
for seq in 0..count {
|
||||||
|
let seq = u32::try_from(seq).expect("sample count should fit in u32");
|
||||||
|
collector.process(&sent_event(seq, base));
|
||||||
|
collector.process(&reply_event(seq, base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measure_config(label: &str, config: StatsConfig, count: usize) {
|
||||||
|
reset_alloc_counter();
|
||||||
|
|
||||||
|
let baseline_current = current_allocated();
|
||||||
|
let baseline_peak = peak_allocated();
|
||||||
|
|
||||||
|
let mut collector = StatsCollector::new(config);
|
||||||
|
feed_successful_probes(&mut collector, count);
|
||||||
|
|
||||||
|
let snapshot = collector.snapshot();
|
||||||
|
|
||||||
|
let current = current_allocated().saturating_sub(baseline_current);
|
||||||
|
let peak = peak_allocated().saturating_sub(baseline_peak);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{label:<42} probes={count:<8} current={:>12} B ({:>8.2} MiB) peak={:>12} B ({:>8.2} MiB) rtt_count={} median={:?} ipdv_pairs={}",
|
||||||
|
current,
|
||||||
|
mib(current),
|
||||||
|
peak,
|
||||||
|
mib(peak),
|
||||||
|
snapshot.rtt.primary.count,
|
||||||
|
snapshot.rtt.primary.median_ns,
|
||||||
|
snapshot.ipdv.round_trip.count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "prints approximate heap allocation growth for stats configurations"]
|
||||||
|
fn print_storage_growth_allocations() {
|
||||||
|
println!();
|
||||||
|
println!("irtt-stats heap allocation growth");
|
||||||
|
println!("=================================");
|
||||||
|
println!(
|
||||||
|
"Note: these numbers are approximate allocator-level deltas for this test binary."
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"They include collection growth and Box allocations, but also any incidental allocations during the measured section."
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for count in [10usize, 1_000, 100_000] {
|
||||||
|
measure_config("finite", StatsConfig::finite(), count);
|
||||||
|
|
||||||
|
measure_config("continuous", StatsConfig::continuous(), count);
|
||||||
|
|
||||||
|
measure_config(
|
||||||
|
"continuous + rolling_count=1_000",
|
||||||
|
StatsConfig {
|
||||||
|
samples: SampleMode::RunningOnly,
|
||||||
|
rolling_count: Some(1_000),
|
||||||
|
rolling_time: None,
|
||||||
|
},
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
|
||||||
|
measure_config(
|
||||||
|
"continuous + rolling_count=100_000",
|
||||||
|
StatsConfig {
|
||||||
|
samples: SampleMode::RunningOnly,
|
||||||
|
rolling_count: Some(100_000),
|
||||||
|
rolling_time: None,
|
||||||
|
},
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@ pub(crate) enum StatsEvent {
|
|||||||
},
|
},
|
||||||
DuplicateReply {
|
DuplicateReply {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
bytes: usize,
|
|
||||||
},
|
},
|
||||||
Loss {
|
Loss {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
@@ -32,7 +31,6 @@ pub(crate) enum StatsEvent {
|
|||||||
},
|
},
|
||||||
UntrackedLate {
|
UntrackedLate {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
bytes: usize,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,17 +96,11 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
|
|||||||
*bytes,
|
*bytes,
|
||||||
)),
|
)),
|
||||||
}),
|
}),
|
||||||
ClientEvent::LateReply {
|
ClientEvent::LateReply { received_at, .. } => Some(StatsEvent::UntrackedLate {
|
||||||
received_at, bytes, ..
|
|
||||||
} => Some(StatsEvent::UntrackedLate {
|
|
||||||
at: received_at.mono,
|
at: received_at.mono,
|
||||||
bytes: *bytes,
|
|
||||||
}),
|
}),
|
||||||
ClientEvent::DuplicateReply {
|
ClientEvent::DuplicateReply { received_at, .. } => Some(StatsEvent::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 }),
|
||||||
@@ -123,10 +115,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,17 +148,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);
|
let rtt_primary_ns = signed_duration_ns(rtt.effective_signed);
|
||||||
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.map(signed_duration_ns),
|
rtt_adjusted_ns: rtt.adjusted_signed.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(signed_duration_ns),
|
.map(duration_ns_i128),
|
||||||
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(signed_duration_ns),
|
.map(duration_ns_i128),
|
||||||
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),
|
||||||
@@ -203,7 +195,7 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
fn signed_duration_ns(duration: SignedDuration) -> i128 {
|
||||||
duration.as_nanos()
|
duration.ns
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,21 +12,20 @@ pub fn ts(ms: u64) -> ClientTimestamp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn adjusted_rtt(raw_ms: u64, adjusted_ms: i128) -> RttSample {
|
pub fn rtt(raw_ms: u64, effective_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: Some(adjusted),
|
adjusted: u64::try_from(effective_ms).ok().map(Duration::from_millis),
|
||||||
effective: adjusted,
|
effective: u64::try_from(effective_ms)
|
||||||
}
|
.ok()
|
||||||
}
|
.map(Duration::from_millis)
|
||||||
|
.unwrap_or_else(|| Duration::from_millis(raw_ms)),
|
||||||
pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
|
adjusted_signed: Some(SignedDuration {
|
||||||
let raw = Duration::from_millis(raw_ms);
|
ns: effective_ms * 1_000_000,
|
||||||
RttSample {
|
}),
|
||||||
raw,
|
effective_signed: SignedDuration {
|
||||||
adjusted: None,
|
ns: effective_ms * 1_000_000,
|
||||||
effective: SignedDuration::from_duration(raw),
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +41,7 @@ pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn adjusted_reply(seq: u32, raw_ms: u64, adjusted_ms: i128) -> ClientEvent {
|
pub fn reply(seq: u32, raw_ms: u64, effective_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),
|
||||||
@@ -61,7 +52,7 @@ fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> 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: rtt(raw_ms, effective_ms),
|
||||||
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),
|
||||||
@@ -72,8 +63,8 @@ fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> 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(SignedDuration::from_nanos(1_000_000)),
|
client_to_server: Some(Duration::from_millis(1)),
|
||||||
server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
|
server_to_client: Some(Duration::from_millis(2)),
|
||||||
}),
|
}),
|
||||||
received_stats: Some(ReceivedStatsSample {
|
received_stats: Some(ReceivedStatsSample {
|
||||||
count: Some(seq + 1),
|
count: Some(seq + 1),
|
||||||
@@ -84,11 +75,7 @@ fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> ClientEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unadjusted_late_reply(seq: u32, raw_ms: u64) -> ClientEvent {
|
pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> 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,
|
||||||
@@ -101,7 +88,7 @@ fn late_reply_from(reply: ClientEvent) -> ClientEvent {
|
|||||||
bytes,
|
bytes,
|
||||||
packet_meta,
|
packet_meta,
|
||||||
..
|
..
|
||||||
} = reply
|
} = reply(seq, raw_ms, effective_ms)
|
||||||
else {
|
else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use common::{adjusted_reply, sent, ts, unadjusted_late_reply, unadjusted_reply};
|
use common::{late_reply, reply, sent, ts};
|
||||||
use irtt_client::{
|
use irtt_client::{ClientEvent, PacketMeta};
|
||||||
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(&unadjusted_reply(0, 10));
|
collector.process(&reply(0, 10, 9));
|
||||||
collector.process(&unadjusted_reply(1, 20));
|
collector.process(&reply(1, 20, 19));
|
||||||
|
|
||||||
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);
|
||||||
@@ -28,7 +25,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(&unadjusted_reply(seq, 10));
|
collector.process(&reply(seq, 10, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
@@ -41,24 +38,23 @@ 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(&adjusted_reply(0, 1, -2));
|
collector.process(&reply(0, 1, -2));
|
||||||
collector.process(&adjusted_reply(1, 10, 8));
|
collector.process(&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_duplicate_measurements() {
|
fn late_unique_counts_and_duplicates_do_not_update_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(&unadjusted_reply(1, 10));
|
collector.process(&reply(1, 10, 9));
|
||||||
collector.process(&unadjusted_late_reply(0, 20));
|
collector.process(&late_reply(0, 20, 19));
|
||||||
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(),
|
||||||
@@ -72,7 +68,6 @@ fn late_unique_counts_and_duplicates_do_not_update_duplicate_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);
|
||||||
@@ -88,7 +83,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(&unadjusted_late_reply(0, 10));
|
collector.process(&late_reply(0, 10, 9));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert_eq!(snapshot.events.loss_events, 1);
|
assert_eq!(snapshot.events.loss_events, 1);
|
||||||
@@ -115,40 +110,12 @@ 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(&unadjusted_reply(0, 10));
|
let first = collector.process(&reply(0, 10, 10));
|
||||||
let gap = collector.process(&unadjusted_reply(2, 15));
|
let gap = collector.process(&reply(2, 15, 15));
|
||||||
let adjacent = collector.process(&unadjusted_reply(3, 12));
|
let adjacent = collector.process(&reply(3, 12, 12));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
assert!(first.contributed_sample);
|
assert!(first.contributed_sample);
|
||||||
@@ -163,44 +130,11 @@ 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(&unadjusted_reply(1, 20));
|
collector.process(&reply(1, 20, 20));
|
||||||
let update = collector.process(&unadjusted_late_reply(0, 10));
|
let update = collector.process(&late_reply(0, 10, 10));
|
||||||
|
|
||||||
let snapshot = collector.snapshot();
|
let snapshot = collector.snapshot();
|
||||||
|
|
||||||
@@ -214,8 +148,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(&unadjusted_reply(0, 10));
|
collector.process(&reply(0, 10, 10));
|
||||||
let update = collector.process(&unadjusted_reply(1, 13));
|
let update = collector.process(&reply(1, 13, 13));
|
||||||
|
|
||||||
assert!(update.contributed_sample);
|
assert!(update.contributed_sample);
|
||||||
|
|
||||||
@@ -228,9 +162,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(&unadjusted_reply(0, 10));
|
let first = collector.process(&reply(0, 10, 10));
|
||||||
let gap = collector.process(&unadjusted_reply(2, 20));
|
let gap = collector.process(&reply(2, 20, 20));
|
||||||
let fill = collector.process(&unadjusted_reply(1, 13));
|
let fill = collector.process(&reply(1, 13, 13));
|
||||||
|
|
||||||
assert!(first.contributed_sample);
|
assert!(first.contributed_sample);
|
||||||
assert!(first.ipdv_pairs.is_empty());
|
assert!(first.ipdv_pairs.is_empty());
|
||||||
@@ -257,7 +191,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(&unadjusted_reply(0, 10));
|
collector.process(&reply(0, 10, 9));
|
||||||
collector.process(&ClientEvent::LateReply {
|
collector.process(&ClientEvent::LateReply {
|
||||||
seq: 9,
|
seq: 9,
|
||||||
highest_seen: 10,
|
highest_seen: 10,
|
||||||
@@ -276,62 +210,6 @@ 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]
|
||||||
@@ -342,8 +220,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(&unadjusted_reply(0, 10));
|
collector.process(&reply(0, 10, 10));
|
||||||
collector.process(&unadjusted_reply(1, 20));
|
collector.process(&reply(1, 20, 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);
|
||||||
@@ -383,7 +261,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(&unadjusted_reply(0, 10));
|
collector.process(&reply(0, 10, 10));
|
||||||
|
|
||||||
let loss = collector.snapshot().loss;
|
let loss = collector.snapshot().loss;
|
||||||
assert_eq!(loss.lost_packets, 1);
|
assert_eq!(loss.lost_packets, 1);
|
||||||
|
|||||||
+1
-2
@@ -4,11 +4,10 @@ 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.32.0"
|
cargo-dist-version = "0.31.0"
|
||||||
# CI backends to support
|
# CI backends to support
|
||||||
ci = "github"
|
ci = "github"
|
||||||
# The installers to generate for each app
|
# The installers to generate for each app
|
||||||
installers = []
|
installers = []
|
||||||
# Target platforms to build apps for (Rust target-triple syntax)
|
# Target platforms to build apps for (Rust target-triple syntax)
|
||||||
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
|
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
|
||||||
features = ["tui"]
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# 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.
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,716 +0,0 @@
|
|||||||
#!/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