Compare commits

..
1 Commits
Author SHA1 Message Date
Nils Andreas Svee b42fa20d2c test(irtt-stats): add memory growth benchmarks
Introduce tests with a custom global allocator to measure and report heap allocation trends across different statistics configurations. Note: temporarily commented out `#![forbid(unsafe_code)]` to support the counting allocator implementation.
2026-05-13 16:58:34 +07:00
80 changed files with 5196 additions and 8940 deletions

No files matched your search

+5 -19
View File
@@ -23,27 +23,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
zizmor:
name: zizmor
runs-on: ubuntu-latest
@@ -56,11 +42,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with:
persona: regular
+4 -43
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
@@ -44,7 +44,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
@@ -63,38 +63,6 @@ jobs:
--locked \
-- -D warnings
- name: Run clippy without default features
run: |
cargo clippy \
--workspace \
--all-targets \
--no-default-features \
--locked \
-- -D warnings
check:
name: cargo check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
cache-key: check
- name: Run check without default features
run: |
cargo check \
--workspace \
--no-default-features \
--locked
test:
name: cargo test
runs-on: ubuntu-latest
@@ -102,7 +70,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
@@ -119,13 +87,6 @@ jobs:
--all-features \
--locked
- name: Run tests without default features
run: |
cargo test \
--workspace \
--no-default-features \
--locked
docs:
name: cargo doc
runs-on: ubuntu-latest
@@ -136,7 +97,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
-75
View File
@@ -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
+12 -12
View File
@@ -64,9 +64,9 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.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
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@@ -82,7 +82,7 @@ jobs:
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@@ -131,7 +131,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -158,7 +158,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@@ -180,14 +180,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -205,7 +205,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-build-global
path: |
@@ -230,14 +230,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -250,14 +250,14 @@ jobs:
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
exit 0
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
+5 -20
View File
@@ -38,12 +38,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2
with:
command: check
arguments: --all-features
@@ -56,24 +56,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/release.yml
shellcheck: false
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
Generated
+18 -523
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "1.0.0"
@@ -44,7 +38,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -55,14 +49,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
@@ -82,21 +76,6 @@ dependencies = [
"objc2",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -151,9 +130,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmov"
version = "0.5.4"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
[[package]]
name = "colorchoice"
@@ -161,56 +140,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "crypto-common"
version = "0.2.2"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
@@ -223,7 +163,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -235,40 +175,6 @@ dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.11.3"
@@ -293,45 +199,6 @@ dependencies = [
"objc2",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -356,50 +223,20 @@ dependencies = [
"typenum",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "irtt-cli"
version = "0.3.0"
version = "0.2.0"
dependencies = [
"clap",
"crossterm",
"ctrlc",
"irtt-client",
"irtt-proto",
"irtt-stats",
"ratatui",
]
[[package]]
name = "irtt-client"
version = "0.3.0"
version = "0.2.0"
dependencies = [
"irtt-proto",
"libc",
@@ -409,7 +246,7 @@ dependencies = [
[[package]]
name = "irtt-proto"
version = "0.3.0"
version = "0.2.0"
dependencies = [
"hmac",
"md-5",
@@ -419,7 +256,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.3.0"
version = "0.2.0"
dependencies = [
"irtt-client",
]
@@ -430,57 +267,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "md-5"
version = "0.11.0"
@@ -491,18 +283,6 @@ dependencies = [
"digest",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -536,35 +316,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -583,148 +334,22 @@ dependencies = [
"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]]
name = "socket2"
version = "0.6.4"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"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]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -764,9 +389,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.20.1"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
@@ -774,84 +399,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -860,67 +419,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+3 -3
View File
@@ -16,9 +16,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.3.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.3.0", path = "crates/irtt-stats" }
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.2.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" }
[profile.release]
opt-level = "z"
+3 -37
View File
@@ -10,17 +10,13 @@ It is not the upstream IRTT project. For the original implementation, protocol b
This repository currently provides:
- `irtt-rs`, the canonical multi-applet binary
- `irtt-cli`, a stream/text IRTT-compatible client applet
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
- `irtt-cli`, a command-line IRTT-compatible client
- `irtt-client`, a Rust library for running client sessions and consuming events
- 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
Server support is not implemented.
The installed binary is intentionally not named `irtt` to avoid conflicting or
confusing overlap with upstream IRTT.
## Install
@@ -34,24 +30,12 @@ cd irtt-rs
cargo install --path crates/irtt-cli
```
The default install provides the normal client-oriented binaries, including
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
```sh
cargo install --path crates/irtt-cli --features tui
```
Or run it directly without installing:
```sh
cargo run -p irtt-cli -- <server>
```
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`,
`irtt-tui`, and future server applet names to it. Applet dispatch is based on
the invoked binary name.
## CLI usage
Basic test:
@@ -81,22 +65,10 @@ irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us
```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
interactive probing. It defaults to continuous probing, equivalent to
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
session gracefully.
```sh
irtt-tui <server>
irtt-tui <server> --duration 30s
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
```
For available options:
```sh
irtt-cli --help
irtt-tui --help
```
## Machine output
@@ -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:
```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.
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:
+5 -26
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-cli"
version = "0.3.0"
version = "0.2.0"
description = "Command-line IRTT-compatible client"
edition.workspace = true
license.workspace = true
@@ -11,34 +11,13 @@ readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
doc = false
[[bin]]
name = "irtt-rs"
path = "src/main.rs"
[[bin]]
name = "irtt-cli"
path = "src/main.rs"
required-features = ["client"]
[[bin]]
name = "irtt-tui"
path = "src/main.rs"
required-features = ["tui"]
[features]
default = ["client", "stats"]
client = ["dep:irtt-client", "dep:irtt-proto"]
stats = ["client", "dep:irtt-stats"]
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
default = ["stats"]
stats = ["dep:irtt-stats"]
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.28.1", optional = true }
ctrlc = "3.5.2"
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-client.workspace = true
irtt-proto.workspace = true
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.29.0", optional = true }
-85
View File
@@ -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
);
}
}
-136
View File
@@ -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());
}
}
-12
View File
@@ -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;
-656
View File
@@ -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());
}
}
-485
View File
@@ -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);
}
}
-7
View File
@@ -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.
-134
View File
@@ -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);
}
}
-6
View File
@@ -1,6 +0,0 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
-216
View File
@@ -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
File diff suppressed because it is too large. Load diff
+447 -60
View File
@@ -1,19 +1,40 @@
use std::{
env,
ffi::OsString,
io::{self, Write},
process::ExitCode,
sync::{atomic::AtomicBool, Arc},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
};
use clap::Parser;
use irtt_cli::{
applet::{
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
},
signal::install_signal_handler,
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
};
use irtt_client::{Client, ClientEvent, OpenOutcome, RecvBudget};
#[cfg(test)]
use irtt_client::ClientTimestamp;
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
fn main() -> ExitCode {
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,
Err(err) => {
eprintln!("irtt-rs: {err}");
@@ -22,71 +43,437 @@ fn main() -> ExitCode {
}
}
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
let requested = match detect_applet_from_argv0(argv0) {
Some(applet) => applet,
None => default_requested_applet()?,
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut output = EventOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_finite_summary: !continuous,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
};
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => Err("server applet is not available in this build".into()),
let mut client = Client::connect(args.to_client_config())?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let open = client.open()?;
output.print_event(open_event(&open))?;
let mut interrupted = false;
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
let events = client.send_probe()?;
output.print_events(&events)?;
}
let events = client.recv_available(RECV_BUDGET)?;
output.print_events(&events)?;
let events = client.poll_timeouts()?;
output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
sleep_until_next_send(client.next_send_deadline());
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if should_drain_final(continuous, interrupted) {
drain_final_replies(&mut client, &mut output)?;
}
let events = client.poll_timeouts()?;
output.print_events(&events)?;
let events = client.close()?;
output.print_events(&events)?;
output.print_summary()?;
output.out.flush()?;
Ok(())
}
fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
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>> {
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
AppletAvailability::Client => Ok(RequestedApplet::Client),
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
AppletAvailability::Server => Ok(RequestedApplet::Server),
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
fn drain_final_replies<W: Write>(
client: &mut Client,
output: &mut EventOutput<'_, W>,
) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
loop {
let mut printed = false;
let events = client.recv_available(RECV_BUDGET)?;
printed |= !events.is_empty();
output.print_events(&events)?;
let events = client.poll_timeouts()?;
printed |= !events.is_empty();
output.print_events(&events)?;
if client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !printed {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
fn sleep_until_next_send(deadline: Option<Instant>) {
let sleep_for = match deadline {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
#[cfg(feature = "client")]
fn run_client_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
irtt_cli::cmd::client::run_stream(args, shutdown_requested)
struct EventOutput<'a, W: Write> {
mode: irtt_cli::OutputMode,
human_options: HumanOutputOptions,
print_finite_summary: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
}
#[cfg(not(feature = "client"))]
fn run_client_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
impl<W: Write> EventOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = irtt_cli::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
Some(format_human_event_with_options(
event,
Some(stats_update.into()),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_finite_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
irtt_cli::summary::format_summary_with_options(
&self.stats.snapshot(),
irtt_cli::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "tui")]
fn run_tui_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
#[cfg(test)]
mod shutdown_tests {
use super::*;
#[test]
fn shutdown_flag_stops_run_loop() {
let shutdown = AtomicBool::new(false);
assert!(should_continue_run(true, Some(Instant::now()), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_continue_run(true, Some(Instant::now()), &shutdown));
}
#[test]
fn shutdown_flag_suppresses_due_probe_send() {
let shutdown = AtomicBool::new(false);
let due = Instant::now() - Duration::from_millis(1);
assert!(should_send_probe(Some(due), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_send_probe(Some(due), &shutdown));
}
#[test]
fn interrupted_continuous_run_uses_final_drain_before_close() {
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
assert!(should_drain_final(false, false));
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use irtt_client::{PacketMeta, RttSample, SignedDuration};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::UNIX_EPOCH,
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: Duration::from_micros(rtt_us),
adjusted_signed: None,
effective_signed: SignedDuration {
ns: i128::from(rtt_us) * 1_000,
},
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: Duration::from_micros(1200),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_finite_summary: true,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_output_does_not_print_finite_summary() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: Duration::from_micros(1),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
}
-223
View File
@@ -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
}
-10
View File
@@ -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));
}
}
-2
View File
@@ -1,2 +0,0 @@
#[cfg(feature = "client")]
pub mod client;
-10
View File
@@ -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)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
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, "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,
@@ -206,8 +198,10 @@ mod tests {
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
@@ -238,7 +232,6 @@ mod tests {
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
#[test]
@@ -252,8 +245,10 @@ mod tests {
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: None,
one_way: None,
@@ -264,32 +259,13 @@ mod tests {
let output = format_summary_with_options(
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
SummaryFormatOptions { verbose: true },
);
assert!(output.contains("raw RTT"));
assert!(output.contains("adjusted RTT"));
}
#[test]
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-client"
version = "0.3.0"
version = "0.2.0"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
@@ -14,7 +14,7 @@ categories = ["network-programming"]
[dependencies]
irtt-proto.workspace = true
libc = { version = "0.2.186", optional = true }
socket2 = { version = "0.6.4", features = ["all"] }
socket2 = { version = "0.6.3", features = ["all"] }
thiserror = "2.0.18"
[features]
+46 -49
View File
@@ -32,12 +32,6 @@ use crate::{
const MAX_OPEN_PACKET_SIZE: usize = 512;
const MIN_RECV_BUFFER_SIZE: usize = 2048;
/// Low-level synchronous IRTT client.
///
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
/// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use [`ManagedClient`](crate::ManagedClient)
/// instead.
#[derive(Debug)]
pub struct Client {
config: ClientConfig,
@@ -51,11 +45,6 @@ pub struct Client {
}
impl Client {
/// Resolve the configured server and create a connected UDP socket.
///
/// This validates local configuration and prepares the open request, but it
/// does not contact the server. Call [`open`](Self::open) to perform the
/// IRTT open exchange.
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
validate_open_timeouts(&config.open_timeouts)?;
if config.max_pending_probes == 0 {
@@ -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> {
match self.phase {
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> {
let token = match self.phase {
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> {
let session = self.session.as_ref()?;
if session.sending_done {
@@ -183,24 +161,14 @@ impl Client {
Some(session.next_send_at)
}
/// Return the local timeout used to classify pending probes as lost.
pub fn probe_timeout(&self) -> Duration {
self.config.probe_timeout
}
/// Send one echo probe if the negotiated run is still active.
///
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
/// list when the run duration has elapsed and no further probe should be
/// sent.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(None)
}
/// Receive and classify at most one datagram from the socket.
///
/// Returns an empty event list when the socket read would block or times
/// out. Malformed or unrelated datagrams are reported as warning events.
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(None)
}
@@ -347,8 +315,6 @@ impl Client {
self.process_echo_reply(reply, packet_len, now, datagram.meta)
}
/// Receive and classify datagrams until a receive produces no events or the
/// receive budget is exhausted.
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
let mut all_events = Vec::new();
for _ in 0..budget.max_packets {
@@ -361,6 +327,12 @@ impl Client {
Ok(all_events)
}
#[cfg(test)]
pub(crate) fn recv_buffer_size(&self) -> usize {
recv_buffer_size(self.config.hmac_key.is_some(), self.negotiated.as_ref())
.expect("test params must have a non-negative packet length")
}
/// Polls for probes that have timed out as of the current monotonic time.
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.poll_timeouts_at(Instant::now())
@@ -400,11 +372,6 @@ impl Client {
Ok(events)
}
/// Return whether the current run has completed.
///
/// A normal run is complete once no more probes will be sent and all
/// pending probes have either replied or timed out. No-test and closed
/// sessions are also considered complete.
pub fn is_run_complete(&self) -> bool {
let Some(session) = self.session.as_ref() else {
return matches!(
@@ -427,6 +394,24 @@ impl Client {
.map_or(0, |session| session.packets_sent)
}
#[cfg(test)]
fn process_received_packet(
&mut self,
packet: &[u8],
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> {
let packet_len = packet.len();
let Some(reply) = self.decode_received_packet(packet) else {
return Ok(vec![ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
message: "dropped malformed or unrelated packet".to_owned(),
at: now,
}]);
};
self.process_echo_reply(reply, packet_len, now, meta)
}
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
let negotiated = self
.negotiated
@@ -472,7 +457,7 @@ impl Client {
.is_some_and(|h| sequence_is_before(wire_seq, h));
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
update_highest_received(session, wire_seq);
session.completed.insert(wire_seq);
let mut events = Vec::new();
@@ -509,7 +494,7 @@ impl Client {
}
Ok(events)
} 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 {
seq: wire_seq,
remote: self.remote,
@@ -522,7 +507,7 @@ impl Client {
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
let received_stats = build_received_stats(&reply);
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
update_highest_received(session, wire_seq);
session.completed.insert(wire_seq);
let mut events = vec![ClientEvent::LateReply {
@@ -703,10 +688,14 @@ impl Client {
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(Some(ts))
}
fn recv_once_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(Some(ts))
}
}
fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
fn update_highest_received(session: &mut ActiveSession, wire_seq: u32) {
session.highest_received_seq = Some(session.highest_received_seq.map_or(wire_seq, |h| {
if sequence_is_after(wire_seq, h) {
wire_seq
} else {
@@ -766,14 +755,22 @@ fn compute_rtt(
let server_processing = compute_server_processing(ts);
let adjusted = server_processing
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
let adjusted = server_processing.and_then(|sp| raw.checked_sub(sp));
let effective = adjusted.unwrap_or(raw);
let adjusted_signed = server_processing.map(|sp| SignedDuration {
ns: duration_ns_i128(raw) - duration_ns_i128(sp),
});
let effective_signed = adjusted_signed.unwrap_or(SignedDuration {
ns: duration_ns_i128(raw),
});
RttSample {
raw,
adjusted,
effective,
adjusted_signed,
effective_signed,
}
}
@@ -828,11 +825,11 @@ fn compute_one_way(
let c2s = server_recv_wall
.zip(client_send_ns)
.and_then(|(srv, cli)| srv.checked_sub(cli))
.map(|d| SignedDuration::from_nanos(i128::from(d)));
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
let s2c = client_recv_ns
.zip(server_send_wall)
.and_then(|(cli, srv)| cli.checked_sub(srv))
.map(|d| SignedDuration::from_nanos(i128::from(d)));
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
if c2s.is_none() && s2c.is_none() {
return None;
@@ -68,6 +68,31 @@ fn send_probe_fails_after_close() {
server.join();
}
#[test]
fn close_clears_timed_out_metadata() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
client.poll_timeouts().unwrap();
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
client.close().unwrap();
assert!(client.session.is_none());
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
+99 -81
View File
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
}
#[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] {
let config = ClientConfig {
length,
@@ -62,12 +62,18 @@ fn params_from_config_accepts_boundary_values() {
i64::from(length)
);
}
}
#[test]
fn params_from_config_rejects_oversized_udp_payload_length() {
let config = ClientConfig {
dscp: 63,
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..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]
@@ -80,85 +86,97 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
}
#[test]
fn params_from_config_rejects_invalid_values() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let cases = [
(
"oversized UDP payload",
ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..ClientConfig::default()
},
"packet length",
),
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
},
"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",
),
];
fn params_from_config_rejects_zero_finite_duration() {
let config = ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration must be greater than zero; use None for continuous mode"
));
}
for (name, config, expected_reason) in cases {
assert!(
matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
),
"{name} should fail with InvalidConfig containing {expected_reason:?}"
);
}
#[test]
fn params_from_config_rejects_zero_interval() {
let config = ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
));
}
#[test]
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let 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]
@@ -1,5 +1,20 @@
use super::*;
#[test]
fn no_test_open_close_succeeds_on_open_reply_close() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
assert!(matches!(client.close(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -1,5 +1,16 @@
use super::*;
#[test]
fn address_resolution_connects_to_local_fake_server() {
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let client = Client::connect(default_test_config(server.addr)).unwrap();
client.socket.send(b"ping").unwrap();
assert_eq!(server.rx.recv().unwrap(), b"ping");
server.join();
}
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -9,6 +20,7 @@ fn successful_open_handshake() {
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
server.join();
}
@@ -57,6 +69,27 @@ fn open_timeout_after_all_timeouts() {
server.join();
}
#[test]
fn open_restores_configured_read_timeout_after_timeout() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(450)),
..crate::SocketConfig::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let start = std::time::Instant::now();
let mut buf = [0_u8; 1];
assert!(client.socket.recv(&mut buf).is_err());
assert!(start.elapsed() >= Duration::from_millis(350));
server.join();
}
#[test]
fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
File diff suppressed because it is too large. Load diff
@@ -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(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let events = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(events.len(), 3);
for (seq, event) in events.iter().enumerate() {
assert!(matches!(
event,
ClientEvent::EchoReply {
seq: actual_seq,
..
} if *actual_seq == seq as u32
));
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_available_respects_packet_budget() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let first = client
.recv_available(RecvBudget { max_packets: 1 })
.unwrap();
assert_eq!(first.len(), 1);
assert!(matches!(&first[0], ClientEvent::EchoReply { seq: 0, .. }));
let second = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(second.len(), 1);
assert!(matches!(&second[0], ClientEvent::EchoReply { seq: 1, .. }));
client.close().unwrap();
server.join();
}
@@ -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, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
if let ClientEvent::EchoReply {
received_stats,
server_timing,
..
} = &events[0]
{
assert!(received_stats.is_none());
assert!(server_timing.is_none());
} else {
panic!("expected EchoReply");
}
server.join();
}
// ---------- Regression tests ----------
#[test]
fn short_echo_reply_does_not_emit_echo_reply() {
let params = Params {
length: 64,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.truncate(echo_packet_len(false, &params) - 1);
socket.send_to(&reply, peer).unwrap();
}
});
let config = ClientConfig {
length: 64,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn overlong_datagram_detection_uses_extra_receive_byte() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.push(0);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn exact_length_echo_reply_still_emits_echo_reply() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoReply { bytes, .. }] if *bytes == echo_packet_len(false, &params)
));
client.close().unwrap();
server.join();
}
@@ -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, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now0).is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now1).is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client.send_probe_at(now2).unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
@@ -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, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
thread::sleep(Duration::from_millis(10));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events1 = client.recv_once().unwrap();
assert_eq!(events1.len(), 1);
assert!(matches!(&events1[0], ClientEvent::EchoReply { .. }));
thread::sleep(Duration::from_millis(30));
let events2 = client.recv_once().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(
&events2[0],
ClientEvent::DuplicateReply { seq: 0, .. }
));
server.join();
}
#[test]
fn out_of_order_reply_emits_late_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
seqs.push(seq);
}
}
let ts = TimestampFields::default();
let reply1 = echo_reply_packet(TOKEN, seqs[1], &params, &ts, None);
socket.send_to(&reply1, peer).unwrap();
thread::sleep(Duration::from_millis(10));
let reply0 = echo_reply_packet(TOKEN, seqs[0], &params, &ts, None);
socket.send_to(&reply0, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let ev1 = client.recv_once().unwrap();
assert_eq!(ev1.len(), 1);
assert!(matches!(&ev1[0], ClientEvent::EchoReply { seq: 1, .. }));
thread::sleep(Duration::from_millis(30));
let ev2 = client.recv_once().unwrap();
assert_eq!(ev2.len(), 1);
match &ev2[0] {
ClientEvent::LateReply {
seq,
highest_seen,
rtt,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*highest_seen, 1);
assert!(rtt.is_some());
}
other => panic!("expected LateReply, got {other:?}"),
}
server.join();
}
@@ -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(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
seq,
rtt,
received_stats,
server_timing,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*bytes, echo_packet_len(false, &params));
assert!(rtt.raw > Duration::ZERO);
assert_eq!(
rtt.effective,
rtt.adjusted
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
);
assert!(received_stats.is_some());
let stats = received_stats.as_ref().unwrap();
assert_eq!(stats.count, Some(42));
assert_eq!(stats.window, Some(0x07));
assert!(server_timing.is_some());
let st = server_timing.as_ref().unwrap();
assert!(st.processing.is_some());
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
{
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
{
assert_eq!(
packet_meta.dscp,
packet_meta.traffic_class.map(|tc| tc >> 2)
);
assert_eq!(
packet_meta.ecn,
packet_meta.traffic_class.map(|tc| tc & 0b11)
);
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
let params = default_params();
let server_params = params.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
socket.send_to(&reply, peer).unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let (packet, _) = recv_request(&socket, &tx);
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
}
let mut longer = echo_reply_packet(
TOKEN,
seqs[0],
&server_params,
&TimestampFields::default(),
None,
);
longer.extend_from_slice(b"trailing bytes");
socket.send_to(&longer, peer).unwrap();
let shorter = echo_reply_packet(
TOKEN,
seqs[1],
&server_params,
&TimestampFields::default(),
None,
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = client.recv_once().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { seq, bytes, .. } => {
assert_eq!(*seq, 1);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
}
server.join();
}
@@ -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, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
crate::socket_options::apply_dscp_to_socket(&socket, peer, 46).unwrap();
let reply_packet =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(traffic_class) = packet_meta.traffic_class else {
metadata_unavailable_skip(
"echo_reply_metadata_propagates_observed_dscp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert_eq!(traffic_class, 184);
assert_eq!(packet_meta.dscp, Some(46));
assert_eq!(packet_meta.ecn, Some(0));
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(timestamp) = packet_meta.kernel_rx_timestamp else {
kernel_rx_timestamp_unavailable_skip(
"echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert!(timestamp.duration_since(SystemTime::UNIX_EPOCH).is_ok());
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
@@ -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, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
thread::sleep(Duration::from_millis(90));
let ts = TimestampFields {
recv_wall: Some(1_000_000_000),
recv_mono: Some(100_000),
send_wall: Some(1_000_100_000),
send_mono: Some(200_000),
..Default::default()
};
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
let losses = client.poll_timeouts().unwrap();
assert!(matches!(&losses[0], ClientEvent::EchoLoss { seq: 0, .. }));
let late = client.recv_once().unwrap();
match &late[0] {
ClientEvent::LateReply {
seq,
sent_at,
rtt,
server_timing,
one_way,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert!(sent_at.is_some());
assert!(rtt.is_some());
assert!(server_timing.is_some());
assert!(one_way.is_some());
assert_eq!(*bytes, echo_packet_len(false, &default_params()));
let _ = packet_meta;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
assert_packet_meta_unavailable(packet_meta);
}
other => panic!("expected stats-eligible LateReply, got {other:?}"),
}
let duplicate = client.recv_once().unwrap();
assert!(matches!(
&duplicate[0],
ClientEvent::DuplicateReply {
seq: 0,
bytes,
..
} if *bytes == echo_packet_len(false, &default_params())
));
client.close().unwrap();
server.join();
}
#[test]
fn pending_full_does_not_send_packet() {
let params = Params {
duration_ns: 60_000_000_000,
..default_params()
};
let server = silent_open_server(params);
let config = ClientConfig {
duration: Some(Duration::from_secs(60)),
max_pending_probes: 2,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let before_count: Vec<_> = server.rx.try_iter().collect();
let echo_before: Vec<_> = before_count
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(echo_before.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
thread::sleep(Duration::from_millis(30));
let after: Vec<_> = server.rx.try_iter().collect();
let echo_after: Vec<_> = after
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(
echo_after.len(),
0,
"no packet should be sent when pending is full"
);
client.close().unwrap();
server.join();
}
@@ -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 std::time::Instant;
use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
session::ActiveSession,
};
#[test]
fn sequence_ordering_handles_normal_and_wrapped_values() {
@@ -18,10 +24,21 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
#[test]
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);
assert_eq!(highest, Some(0));
update_highest_received(&mut highest, u32::MAX);
assert_eq!(highest, Some(0));
update_highest_received(&mut session, 0);
assert_eq!(session.highest_received_seq, Some(0));
update_highest_received(&mut session, u32::MAX);
assert_eq!(session.highest_received_seq, Some(0));
}
+1 -116
View File
@@ -1,31 +1,12 @@
use std::{net::SocketAddr, time::Duration};
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
/// bytes.
///
/// This is the maximum encoded server-fill string accepted by this client and
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
/// before opening a session.
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt};
pub(crate) const DEFAULT_PORT: u16 = 2112;
/// Largest valid DSCP codepoint accepted by client configuration.
///
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
/// socket traffic-class field when configuring packet priority.
pub const MAX_DSCP_CODEPOINT: u8 = 63;
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
///
/// This is the public user-configuration bound for
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
/// TTL and hop-limit settings are configured as `1..=255`.
pub const MAX_TTL: u32 = 255;
/// Largest UDP payload length accepted by client configuration, in bytes.
///
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
pub const MAX_SERVER_FILL_BYTES: usize = 32;
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
@@ -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_MAX_PENDING: usize = 4096;
/// Configuration for opening and running an IRTT client session.
///
/// This type describes both the protocol parameters sent in the IRTT open
/// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
/// Remote server name or address.
///
/// If no port is present, the default IRTT port 2112 is used. IPv6
/// literals may be supplied either bracketed or unbracketed when the
/// default port should be used.
pub server_addr: String,
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8,
/// Optional HMAC key used to authenticate IRTT packets.
///
/// When present, open, echo, and close packets are encoded and decoded with
/// HMAC authentication. The peer must be configured with the same key.
pub hmac_key: Option<Vec<u8>>,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>,
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub open_timeouts: Vec<Duration>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation_policy: NegotiationPolicy,
/// Local UDP socket configuration.
pub socket_config: SocketConfig,
/// Time after sending a probe before the client reports it as lost.
///
/// This timeout is local client behavior; it is not negotiated with the
/// server. It must be greater than zero.
pub probe_timeout: Duration,
/// Maximum number of probes tracked as pending/timed-out/completed.
///
/// This bounds memory used for reply classification and must be greater
/// than zero. A very small value can reject sends when replies are still
/// outstanding.
pub max_pending_probes: usize,
}
@@ -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)]
pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
///
/// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family.
pub bind_addr: Option<SocketAddr>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
///
/// `None` leaves the platform default unchanged. Values must fit in the
/// platform socket option range; [`MAX_TTL`] is the public configuration
/// bound used by this crate.
pub ttl: Option<u32>,
/// Restrict name resolution to IPv4 addresses.
pub ipv4_only: bool,
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
/// sockets.
pub ipv6_only: bool,
/// Socket read timeout used after the session is open.
///
/// `None` leaves reads blocking for APIs that perform a single receive.
/// Managed sessions may replace `None` or long timeouts with a short
/// timeout so cooperative cancellation can be observed promptly.
pub recv_timeout: Option<Duration>,
}
/// How strictly to handle server-side negotiation restrictions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NegotiationPolicy {
/// Reject any negotiated parameter that is more restrictive or different
/// than requested.
Strict,
/// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams).
Loose,
}
/// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration.
Normal,
/// Complete negotiation without running the echo probe test.
NoTest,
}
/// Bound for a single receive-drain operation.
///
/// This is used by lower-level callers that drive [`Client`](crate::Client)
/// directly and want to cap how many datagrams are processed before returning
/// to their own event loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecvBudget {
/// Maximum number of datagrams to process before returning.
pub max_packets: usize,
}
-110
View File
@@ -2,176 +2,66 @@ use std::{io, time::Duration};
use thiserror::Error;
/// Failure reported while configuring, opening, running, receiving, closing, or
/// joining an IRTT client session.
///
/// Configuration validation errors usually require changing
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
/// Network, protocol, and runtime errors may be transient, but can also reflect
/// peer incompatibility or an invalid session state.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientError {
/// The configured server address could not be resolved to a socket address.
///
/// Check the host, port, address-family restrictions, and name service
/// configuration before retrying.
#[error("failed to resolve server address {addr:?}")]
Resolve { addr: String },
/// A UDP socket operation failed while binding, connecting, sending, or
/// receiving.
///
/// This can be a transient network or OS error, but may also reflect local
/// permissions, routing, or socket configuration.
#[error("socket error: {0}")]
Socket(#[from] io::Error),
/// Applying or reading a socket option failed.
///
/// This is usually caused by an unsupported option, an invalid local value,
/// or a platform-specific socket limitation. Changing socket configuration
/// is more likely to help than retrying unchanged.
#[error("socket option error while trying to {operation} for {remote}: {source}")]
SocketOption {
operation: &'static str,
remote: std::net::SocketAddr,
source: io::Error,
},
/// Restoring the configured read timeout after open negotiation failed.
///
/// The session may have opened successfully, but the socket could not be
/// returned to the caller's requested receive-timeout behavior.
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
ReadTimeoutRestore { source: io::Error },
/// A packet failed IRTT protocol parsing or authentication.
///
/// Likely causes include malformed input, unsupported protocol data, or an
/// HMAC mismatch between client and server.
#[error("protocol error: {0}")]
Protocol(#[from] irtt_proto::ProtoError),
/// No valid open reply was received before all configured open attempts
/// timed out.
///
/// Retrying can succeed when the server or network is temporarily
/// unavailable. Persistent failures usually require changing the address,
/// authentication key, server configuration, or timeout settings.
#[error("all open requests timed out")]
OpenTimeout,
/// An open-attempt timeout is below the client's supported minimum.
///
/// Increase the configured timeout before retrying.
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
OpenTimeoutTooSmall {
timeout: Duration,
minimum: Duration,
},
/// The configured open-attempt timeout list is empty.
///
/// Provide at least one timeout before opening the session.
#[error("open_timeouts must not be empty")]
NoOpenTimeouts,
/// The server explicitly rejected the open request.
///
/// Retrying unchanged is unlikely to help unless the server configuration or
/// load has changed.
#[error("server rejected the open request")]
ServerRejected,
/// A no-test open request received a reply that did not have no-test close
/// semantics.
///
/// This indicates an incompatible or unexpected peer response.
#[error("unexpected no-test open reply")]
UnexpectedNoTestReply,
/// A no-test open reply included a connection token.
///
/// No-test negotiation must not establish a probe session, so a non-zero
/// token indicates an incompatible or unexpected peer response.
#[error("no-test reply included a non-zero connection token: {token}")]
NonZeroNoTestToken { token: u64 },
/// The peer replied with a protocol version different from the requested
/// version.
///
/// This is a protocol compatibility error and requires compatible client
/// and server versions.
#[error("protocol version mismatch: requested {requested}, received {received}")]
ProtocolVersionMismatch { requested: i64, received: i64 },
/// The server returned a zero connection token for a normal test session.
///
/// A normal session requires a non-zero token to correlate echo traffic, so
/// this indicates an invalid peer response.
#[error("server returned a zero connection token")]
ZeroToken,
/// Negotiated parameters were rejected by the configured negotiation policy.
///
/// Use a less restrictive configuration or
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
/// caller accepts documented server restrictions.
#[error("strict negotiation rejected changed params: {reason}")]
NegotiationRejected { reason: String },
/// The requested operation requires an open session, but the client is only
/// connected.
#[error("client is not open")]
NotOpen,
/// The session is already open.
///
/// Continue running, receiving, or closing the existing session instead of
/// opening it again.
#[error("client session is already open")]
AlreadyOpen,
/// The no-test session has already completed.
///
/// Create a new client session to perform another negotiation.
#[error("client session is already completed")]
AlreadyCompleted,
/// The session is already closed.
///
/// Create a new client session before opening or running another test.
#[error("client session is already closed")]
AlreadyClosed,
/// A duration could not be represented as nanoseconds for scheduling or
/// protocol encoding.
///
/// Use smaller configured or negotiated durations.
#[error("duration is too large to encode as nanoseconds")]
DurationOverflow,
/// A client-maintained counter would overflow.
///
/// This is a runtime limit rather than a network failure. Start a new
/// session if the test duration or send volume reaches this limit.
#[error("counter {counter} overflowed")]
CounterOverflow { counter: &'static str },
/// Sending another probe would exceed the configured pending-probe limit.
///
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
/// reduce send rate, or receive replies and timeouts more often before
/// sending again.
#[error("pending probe limit exceeded ({limit})")]
PendingLimitExceeded { limit: usize },
/// Client configuration failed validation before the requested operation.
///
/// The reported reason names a value that must be changed before retrying.
#[error("invalid configuration: {reason}")]
InvalidConfig { reason: String },
/// A managed-session worker thread panicked before producing an outcome.
///
/// This is a runtime failure from joining the managed session, not a
/// protocol response from the server.
#[error("managed client worker thread panicked")]
WorkerPanicked,
}
/// Failure reported while setting up or receiving from an event subscription.
///
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
/// Once a subscription exists, this error type reports delivery failures observed
/// by [`EventSubscription`](crate::EventSubscription).
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EventSubscriptionError {
/// The subscription can no longer receive new events.
///
/// Already queued events are delivered first. After the queue is drained,
/// `recv` and `try_recv` return this error when the hub disconnects the
/// subscriber, the hub is shut down, or the subscription is disconnected by
/// its overflow policy.
#[error("event subscription is disconnected")]
Disconnected,
}
+23 -229
View File
@@ -5,360 +5,139 @@ use std::{
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
/// Result of the IRTT open exchange.
///
/// The lower-level [`Client`](crate::Client) API returns this before any echo
/// probes are driven. Managed sessions publish the contained lifecycle event to
/// subscribers immediately after a successful open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenOutcome {
/// A normal probe session was opened.
Started {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Session token assigned by the server and used on echo/close packets.
token: u64,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
/// A no-test open exchange completed without starting a probe session.
NoTestCompleted {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
}
/// Event emitted by an IRTT client session.
///
/// Events form the public lifecycle and measurement stream for callers. UIs
/// usually display lifecycle, warning, and per-packet events directly; summary
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
/// `LateReply`, and `DuplicateReply` events or pass the stream to
/// `irtt-stats`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent {
/// A normal session has opened and is ready to send echo probes.
SessionStarted {
/// Resolved remote socket address.
remote: SocketAddr,
/// Session token assigned by the server.
token: u64,
/// Negotiated protocol parameters for this session.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// A negotiation-only no-test exchange completed.
///
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
/// expected for this open outcome.
NoTestCompleted {
/// Resolved remote socket address.
remote: SocketAddr,
/// Negotiated protocol parameters returned by the server.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// The client sent a close request and considers the session closed.
///
/// This is a local lifecycle event, not an acknowledgement from the server.
/// Managed sessions disconnect event subscribers after the worker exits,
/// while leaving already queued events available to drain.
SessionClosed {
/// Resolved remote socket address.
remote: SocketAddr,
/// Session token that was closed.
token: u64,
/// Client timestamp when the close request was sent.
at: ClientTimestamp,
},
/// An echo request was sent.
///
/// This records local scheduling and send-call timing. It does not imply a
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
/// packet lifecycle state.
EchoSent {
/// Wire sequence number sent in the echo request.
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent.
scheduled_at: Instant,
/// Client wall/monotonic timestamp captured for the send.
sent_at: ClientTimestamp,
/// Number of bytes passed to the UDP socket.
bytes: usize,
/// Elapsed time spent in the socket send call.
send_call: Duration,
/// Absolute difference between the scheduled send time and actual send
/// timestamp.
timer_error: Duration,
},
/// First in-window reply for a pending echo request.
///
/// This is the primary successful measurement event. RTT and optional
/// server timing/statistics fields should generally be aggregated from this
/// variant.
EchoReply {
/// Wire sequence number from the echo reply.
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the matching request was sent.
sent_at: ClientTimestamp,
/// Client timestamp recorded when the reply was received.
received_at: ClientTimestamp,
/// Round-trip timing sample for this reply.
rtt: RttSample,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// A sent echo request exceeded the local probe timeout before a reply was
/// matched.
///
/// A later datagram for the same sequence may still arrive and will be
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
EchoLoss {
/// Wire sequence number that timed out.
seq: u32,
/// Client timestamp recorded when the request was sent.
sent_at: ClientTimestamp,
/// Monotonic deadline at which the probe was declared lost.
timeout_at: std::time::Instant,
},
/// Additional reply for a sequence that was already completed.
///
/// Duplicates are not primary RTT samples. They are useful for diagnostics
/// and packet counters, but should not be aggregated as successful first
/// replies.
DuplicateReply {
/// Wire sequence number from the duplicate reply.
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the duplicate was received.
received_at: ClientTimestamp,
/// UDP datagram size received from the socket.
bytes: usize,
},
/// Reply for a sequence that was no longer pending.
///
/// This commonly means the probe had already been reported as lost, or the
/// reply arrived out of the client's current tracking window. When the
/// original send metadata is still retained, RTT and one-way timing are
/// included; otherwise those fields are `None`.
LateReply {
/// Wire sequence number from the late reply.
seq: u32,
/// Highest sequence number accepted as an in-window first reply so far.
highest_seen: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Original client send timestamp, when still retained.
sent_at: Option<ClientTimestamp>,
/// Client timestamp recorded when the late reply was received.
received_at: ClientTimestamp,
/// RTT sample computed when the original send timestamp is available.
rtt: Option<RttSample>,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// Non-fatal condition observed while receiving or classifying packets.
///
/// Warning events are diagnostic. They do not close the session by
/// themselves and are not measurement samples.
Warning {
/// Machine-readable warning category.
kind: WarningKind,
/// Human-readable diagnostic text.
message: String,
/// Client timestamp when the warning was emitted.
at: ClientTimestamp,
},
}
/// Category for a non-fatal [`ClientEvent::Warning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WarningKind {
/// A received datagram could not be decoded as a relevant IRTT packet.
MalformedOrUnrelatedPacket,
/// A decoded packet used a token that does not match the open session.
WrongToken,
/// A decoded reply could not be matched to tracked probe state.
UntrackedReply,
}
/// Round-trip timing for one echo reply.
///
/// `raw` is the client-observed elapsed time from sending the echo request to
/// receiving the reply and is therefore non-negative. When the server reports
/// enough monotonic timing to compute processing time, `adjusted` subtracts
/// that server processing from `raw`. `effective` is the value used by reporting
/// and statistics: adjusted when available, otherwise raw converted to a signed
/// value.
///
/// Adjusted and effective RTT values are signed. They may be negative if the
/// reported server processing time is greater than the raw client-observed RTT.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RttSample {
/// Client-observed RTT from send to receive.
pub raw: Duration,
/// Signed RTT adjusted for server processing when server processing is available.
pub adjusted: Option<SignedDuration>,
/// Signed effective RTT used by stats and CLI reporting.
///
/// This is `adjusted` when server processing is available, otherwise `raw`
/// converted to a signed duration.
pub effective: SignedDuration,
pub adjusted: Option<Duration>,
pub effective: Duration,
pub adjusted_signed: Option<SignedDuration>,
pub effective_signed: SignedDuration,
}
/// Signed nanosecond-backed duration used for derived timing values.
///
/// This type preserves negative values that can arise after subtracting server
/// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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)]
pub struct ServerTiming {
/// Server wall-clock receive timestamp, in nanoseconds.
pub receive_wall_ns: Option<i64>,
/// Server monotonic receive timestamp, in nanoseconds.
pub receive_mono_ns: Option<i64>,
/// Server wall-clock send timestamp, in nanoseconds.
pub send_wall_ns: Option<i64>,
/// Server monotonic send timestamp, in nanoseconds.
pub send_mono_ns: Option<i64>,
/// Server wall-clock midpoint timestamp, in nanoseconds.
pub midpoint_wall_ns: Option<i64>,
/// Server monotonic midpoint timestamp, in nanoseconds.
pub midpoint_mono_ns: Option<i64>,
/// Server processing time computed from monotonic receive/send timestamps.
///
/// `None` means the required monotonic timestamps were unavailable or
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
/// was available and zero.
pub processing: Option<Duration>,
}
/// Directional one-way delay samples computed from wall-clock timestamps.
///
/// Each directional field is an `Option<SignedDuration>`. `None` means the
/// required client or server wall-clock timestamp was unavailable, or the
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
/// computed from available timestamps.
///
/// Negative computed values are preserved as `Some` instead of being clamped to
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
/// between the client and server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OneWayDelaySample {
/// Client-to-server delay.
///
/// This is computed from the client send wall time and the server receive
/// wall time when both are available.
pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay.
///
/// This is computed from the server send wall time and the client receive
/// wall time when both are available.
pub server_to_client: Option<SignedDuration>,
pub client_to_server: Option<Duration>,
pub server_to_client: Option<Duration>,
}
/// Server-reported receive counters decoded from an echo reply.
///
/// These fields are optional because the negotiated received-statistics mode or
/// server implementation may omit them. `None` means unavailable; `Some(0)`
/// means the server reported the value and it was zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceivedStatsSample {
/// Server-reported count of received packets, when available.
pub count: Option<u32>,
/// Server-reported receive window value, when available.
pub window: Option<u64>,
}
@@ -373,3 +152,18 @@ pub struct PacketMeta {
pub ecn: Option<u8>,
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
}
#[cfg(test)]
mod tests {
use super::PacketMeta;
#[test]
fn packet_meta_default_is_unavailable() {
let meta = PacketMeta::default();
assert_eq!(meta.traffic_class, None);
assert_eq!(meta.dscp, None);
assert_eq!(meta.ecn, None);
assert_eq!(meta.kernel_rx_timestamp, None);
}
}
-47
View File
@@ -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(
not(all(target_os = "linux", feature = "ancillary")),
forbid(unsafe_code)
+5 -11
View File
@@ -3,31 +3,20 @@ use std::sync::{
Arc,
};
/// Cloneable cooperative cancellation flag.
///
/// Managed sessions use this internally to ask their worker loop to stop. It is
/// exported for callers that want the same simple cancellation primitive in
/// code built around `irtt-client`.
#[derive(Debug, Clone, Default)]
pub struct CancellationToken {
cancelled: Arc<AtomicBool>,
}
impl CancellationToken {
/// Create a token in the non-cancelled state.
pub fn new() -> Self {
Self::default()
}
/// Mark the token as cancelled.
///
/// Cancellation is idempotent and is visible through all clones of the
/// token.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// Return whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
@@ -38,6 +27,11 @@ impl CancellationToken {
mod tests {
use super::*;
#[test]
fn token_starts_uncancelled() {
assert!(!CancellationToken::new().is_cancelled());
}
#[test]
fn cancel_is_idempotent_and_visible_through_clones() {
let token = CancellationToken::new();
+55 -65
View File
@@ -8,17 +8,9 @@ use std::{
use crate::{error::EventSubscriptionError, event::ClientEvent};
/// Configuration for one managed event subscriber.
///
/// Each subscriber has an independent bounded queue. The queue bounds memory
/// growth when event production is faster than that subscriber's consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubscriberConfig {
/// Maximum number of events buffered for this subscriber.
///
/// Must be greater than zero.
pub capacity: usize,
/// Policy applied when publishing to a full subscriber queue.
pub overflow: SubscriberOverflow,
}
@@ -45,11 +37,6 @@ pub enum SubscriberOverflow {
Disconnect,
}
/// Publish/subscribe fan-out for [`ClientEvent`] values.
///
/// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber.
#[derive(Debug, Clone)]
pub struct EventHub {
inner: Arc<HubInner>,
@@ -88,7 +75,6 @@ struct SubscriberState {
}
impl EventHub {
/// Create an empty event hub with no subscribers.
pub fn new() -> Self {
Self {
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(
&self,
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) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
.inner
@@ -171,8 +149,6 @@ impl EventHub {
}
}
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self
.inner
@@ -314,16 +290,14 @@ mod tests {
}
}
fn assert_next(
sub: &EventSubscription,
expected: Result<Option<&str>, EventSubscriptionError>,
) {
match (sub.try_recv(), expected) {
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
(Ok(None), Ok(None)) => {}
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
#[test]
fn publishes_to_one_subscriber() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.publish(event(1));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
}
#[test]
@@ -339,40 +313,56 @@ mod tests {
}
#[test]
fn full_subscriber_overflow_policy_is_applied() {
let cases = [
(
SubscriberOverflow::DropNewest,
Ok(Some("event-1")),
Ok(None),
),
(
SubscriberOverflow::DropOldest,
Ok(Some("event-2")),
Ok(None),
),
(
SubscriberOverflow::Disconnect,
Err(EventSubscriptionError::Disconnected),
Err(EventSubscriptionError::Disconnected),
),
];
fn drop_newest_keeps_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropNewest,
})
.unwrap();
for (overflow, first_expected, second_expected) in cases {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
assert!(sub.try_recv().unwrap().is_none());
}
assert_next(&sub, first_expected);
assert_next(&sub, second_expected);
}
#[test]
fn drop_oldest_replaces_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropOldest,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
assert!(sub.try_recv().unwrap().is_none());
}
#[test]
fn disconnect_removes_full_subscriber() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::Disconnect,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(
sub.try_recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
}
#[test]
-43
View File
@@ -21,12 +21,6 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
const IDLE_SLEEP: Duration = Duration::from_millis(1);
const MAX_SLEEP: Duration = Duration::from_millis(20);
/// Entry point for running a client session on a worker thread.
///
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
/// opens the session, sends probes at the negotiated interval, receives
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
/// run completes or is cancelled.
#[derive(Debug)]
pub struct ManagedClient;
@@ -52,49 +46,26 @@ pub struct ManagedClientSession {
#[must_use = "managed session outcomes contain completion status and counters"]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionOutcome {
/// Why the managed loop stopped.
pub end_reason: SessionEndReason,
/// Number of echo requests sent by the client.
pub packets_sent: u64,
/// Number of first in-window echo replies received.
pub replies_received: u64,
/// Number of duplicate reply events emitted.
pub duplicates: u64,
/// Number of late reply events emitted.
pub late: u64,
/// Number of warning events emitted.
pub warning_events: u64,
}
/// Reason a managed client session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionEndReason {
/// The negotiated finite test duration completed and the client closed the
/// session normally.
TestComplete,
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
/// dropping the session handle.
Cancelled,
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
/// completed after negotiation.
NoTestComplete,
}
impl ManagedClient {
/// Start a managed session without creating an initial event subscription.
///
/// The returned [`ManagedClientSession`] can still create subscriptions via
/// [`ManagedClientSession::subscribe`], but events emitted before a
/// subscriber is registered are not replayed.
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
Self::start_inner(config, None).map(|(session, _)| session)
}
/// Start a managed session and subscribe to events before the worker runs.
///
/// The initial subscription receives the open lifecycle event and
/// subsequent session events, subject to its queue capacity and overflow
/// policy.
pub fn start_with_subscription(
config: ClientConfig,
subscriber_config: SubscriberConfig,
@@ -143,28 +114,14 @@ impl ManagedClient {
}
impl ManagedClientSession {
/// Add another event subscriber to this running managed session.
///
/// The subscription receives only events published after it is registered.
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
self.hub.subscribe(config)
}
/// Request cooperative cancellation of the managed session.
///
/// Cancellation is observed by the worker loop between socket receive,
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
/// wait for the worker and obtain the final [`SessionOutcome`].
pub fn stop(&self) {
self.cancellation.cancel();
}
/// Wait for the managed worker thread to finish.
///
/// On success, returns lifecycle counters for the completed session. If the
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
/// also disconnects all event subscriptions while leaving already queued
/// events available to drain.
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
let worker = self
.worker
+11
View File
@@ -24,6 +24,17 @@ mod tests {
use super::ReceiveMeta;
use crate::event::PacketMeta;
#[test]
fn metadata_default_is_unavailable() {
let meta = ReceiveMeta::default();
let packet_meta = PacketMeta::from(meta);
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[test]
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
let packet_meta = PacketMeta::from(ReceiveMeta {
+16 -1
View File
@@ -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]
fn pending_map_rejects_over_capacity() {
let mut map = PendingMap::new(2);
@@ -208,7 +220,7 @@ mod tests {
}
#[test]
fn bounded_probe_tracking_evicts_oldest_entries() {
fn completed_set_tracks_and_evicts() {
let mut set = CompletedSet::new(3);
set.insert(0);
set.insert(1);
@@ -220,7 +232,10 @@ mod tests {
assert_eq!(set.set.len(), 3);
assert!(!set.contains(0));
assert!(set.contains(3));
}
#[test]
fn timed_out_map_tracks_and_evicts() {
let mut map = TimedOutMap::new(2);
let now = Instant::now();
map.insert(pending(0, now));
-8
View File
@@ -8,16 +8,9 @@ use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
};
/// Protocol parameters accepted for a session.
///
/// `params` contains the server-returned values that the client will use for
/// echo packets. `restrictions` records accepted differences from the request
/// when loose negotiation is enabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedParams {
/// Server-returned protocol parameters used for the session.
pub params: Params,
/// Accepted server restrictions or parameter changes.
pub restrictions: Vec<NegotiationRestriction>,
}
@@ -64,7 +57,6 @@ pub enum NegotiationRestriction {
}
impl NegotiationRestriction {
/// Return a human-readable description of this negotiated restriction.
pub fn message(&self) -> String {
self.to_string()
}
-8
View File
@@ -1,20 +1,12 @@
use std::time::{Instant, SystemTime};
/// Client-side timestamp captured in both wall-clock and monotonic time.
///
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
/// calculations. Monotonic time is used for elapsed client-side timing such as
/// RTT, scheduling, and timeouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientTimestamp {
/// System wall-clock time.
pub wall: SystemTime,
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
pub mono: Instant,
}
impl ClientTimestamp {
/// Capture the current wall-clock and monotonic timestamps.
pub fn now() -> Self {
Self {
wall: SystemTime::now(),
+59 -29
View File
@@ -122,27 +122,47 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
}
#[test]
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
let server_key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
fn missing_client_key_against_hmac_required_server_times_out() {
let key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let 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,
bad_hmac: true
}] if *hmac == expected_hmac_flag
));
server.join();
}
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: false,
bad_hmac: true
}]
));
server.join();
}
#[test]
fn wrong_client_key_against_hmac_required_server_times_out() {
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]
@@ -291,15 +311,25 @@ fn backend_hmac_correct_key_succeeds() {
}
#[test]
fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
fn backend_hmac_wrong_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 = Some(b"wrong-secret".to_vec());
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
#[test]
fn backend_hmac_missing_key_fails() {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
+159 -11
View File
@@ -1,20 +1,17 @@
mod support;
mod protocol_options {
pub mod backend_smoke;
}
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
RttSample, ServerTiming, SignedDuration,
RttSample, ServerTiming,
};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
use support::{
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
RECV_COUNT, RECV_WINDOW, TOKEN,
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
};
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.one_way.is_none());
assert!(reply.rtt.adjusted.is_none());
assert_eq!(
reply.rtt.effective,
SignedDuration::from_duration(reply.rtt.raw)
);
assert_eq!(reply.rtt.effective, reply.rtt.raw);
assert!(reply.rtt.raw > Duration::ZERO);
}
StampAt::Send => {
@@ -352,7 +346,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
assert_eq!(processing, Duration::from_nanos(1));
let adjusted = reply.rtt.adjusted.unwrap();
assert_eq!(reply.rtt.effective, adjusted);
assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
assert!(adjusted < reply.rtt.raw);
}
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);
}
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn dscp_configured_open_close_smoke() {
let mut params = default_params();
@@ -382,3 +416,117 @@ fn dscp_configured_open_close_smoke() {
client.close().unwrap();
server.join();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
@@ -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(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
+108 -44
View File
@@ -1,6 +1,6 @@
mod support;
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
use irtt_client::{Client, ClientConfig, ClientError, ClientEvent, NegotiationPolicy};
use irtt_proto::{Params, TimestampFields};
use support::{
@@ -32,34 +32,63 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
// ─── Config mapping ───
#[test]
fn server_fill_none_short_and_max_map_into_open_params() {
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
let mut params = default_params();
params.server_fill = fill.and_then(server_fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
fill
);
}
fn server_fill_none_produces_no_fill_in_open_params() {
let params = default_params();
let run = run_one_probe(params, TimestampFields::default());
assert!(open_observation_params(&run).server_fill.is_none());
}
#[test]
fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
let config = ClientConfig {
server_fill: Some(fill.to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
fn server_fill_short_value_maps_into_open_params() {
let mut params = default_params();
params.server_fill = server_fill("abc");
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some("abc")
);
}
#[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 ───
@@ -73,26 +102,53 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
}
#[test]
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
for (requested_fill, returned_fill) in [
(Some("rand"), None),
(Some("abc"), Some("xyz")),
(None, Some("unexpected")),
] {
let mut requested = default_params();
requested.server_fill = requested_fill.and_then(server_fill);
fn server_fill_strict_rejects_removed_fill() {
let mut requested = default_params();
requested.server_fill = server_fill("rand");
let mut returned = requested.clone();
returned.server_fill = returned_fill.and_then(server_fill);
let mut returned = requested.clone();
returned.server_fill = None;
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();
}
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_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]
@@ -120,6 +176,14 @@ fn server_fill_loose_allows_server_to_remove_fill() {
// ─── Echo behavior ───
#[test]
fn server_fill_echo_reply_decoded_with_fill_configured() {
let mut params = default_params();
params.server_fill = server_fill("rand");
let run = run_one_probe(params, TimestampFields::default());
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
}
// server_fill is a negotiation/config parameter only; it does not affect
// client-observable reply payload layout. The client sends server_fill in
// the open request and expects the server to use it for reply payload
@@ -275,3 +275,61 @@ macro_rules! debug_eprintln {
}
use debug_eprintln;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_failure_details_include_bind_command_status_and_output() {
let status = Command::new("sh")
.arg("-c")
.arg("exit 7")
.status()
.expect("shell exits");
let failure = StartupFailure {
bind: "127.0.0.1:12345".to_owned(),
command: "\"irtt\" \"server\"".to_owned(),
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
exit_status: Some(status),
error: Some("readiness timed out or server exited before ready".to_owned()),
};
let details = failure.details();
assert!(details.contains("bind: 127.0.0.1:12345"));
assert!(details.contains("command: \"irtt\" \"server\""));
assert!(details.contains("exit status:"));
assert!(details.contains("address already in use"));
}
#[test]
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
let failures = vec![
StartupFailure {
bind: "127.0.0.1:11111".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
output: "stdout: first".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
StartupFailure {
bind: "127.0.0.1:22222".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
output: "stderr: second".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
];
let message = format_startup_failures("irtt", &failures);
assert!(message.contains("after 2 attempt(s)"));
assert!(message.contains("attempt 1/2"));
assert!(message.contains("attempt 2/2"));
assert!(message.contains("127.0.0.1:11111"));
assert!(message.contains("127.0.0.1:22222"));
assert!(message.contains("stdout: first"));
assert!(message.contains("stderr: second"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-proto"
version = "0.3.0"
version = "0.2.0"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
+14
View File
@@ -31,6 +31,20 @@ mod tests {
use super::*;
use crate::{hmac, FLAG_HMAC};
#[test]
fn close_request_sizes() {
let request = CloseRequest {
token: 0x7896_b6ab_8771_5213,
};
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
assert_eq!(
encode_close_request(&request, Some(b"testkey"))
.unwrap()
.len(),
28
);
}
#[test]
fn hmac_close_request_places_token_after_hmac() {
let packet = encode_close_request(
+46
View File
@@ -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]
fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
@@ -109,6 +119,42 @@ mod tests {
assert_eq!(digest, reference);
}
#[test]
fn compute_hmac_in_place_does_not_set_hmac_flag() {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&[1, 2, 3, 4]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(packet[3], FLAG_OPEN);
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
.iter()
.any(|byte| *byte != 0));
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
}
#[test]
fn compute_hmac_in_place_only_writes_hmac_field() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let original = packet.clone();
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
assert_ne!(
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
);
assert_eq!(
&packet[hmac_offset() + HMAC_SIZE..],
&original[hmac_offset() + HMAC_SIZE..]
);
}
#[test]
fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
+20 -6
View File
@@ -155,12 +155,6 @@ mod tests {
#[test]
fn verified_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
assert_eq!(
echo_header_len(
false,
@@ -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]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
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, &params(ReceivedStats::None, StampAt::None, clock)),
16
);
}
}
#[test]
fn negotiated_length_never_smaller_than_header() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
+1 -19
View File
@@ -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)]
pub mod close;
@@ -35,7 +17,7 @@ pub use flags::*;
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
pub const PROTOCOL_VERSION: i64 = 1;
+7 -57
View File
@@ -1,16 +1,5 @@
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
/// Protocol compatibility bound for an encoded `server_fill` parameter.
pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// Low-level wire representation of IRTT open parameters.
///
/// `Params` mirrors the protocol fields closely and can be constructed
/// directly by callers. Direct construction can therefore produce values that
/// should not be sent on the wire, such as an oversized `server_fill` value.
/// Higher-level callers should validate user or configuration input before
/// encoding. The normal `irtt-client` configuration path enforces
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -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> {
let mut out = Vec::new();
push_int(1, self.protocol_version, &mut out);
@@ -54,10 +39,6 @@ impl Params {
out
}
/// Decodes parameters and rejects malformed or incompatible incoming values.
///
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
/// values longer than [`MAX_SERVER_FILL_BYTES`].
pub fn decode(input: &[u8]) -> Result<Self> {
let mut params = Self::default();
let mut pos = 0;
@@ -78,12 +59,6 @@ impl Params {
pos += used;
let len = usize::try_from(len)
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
if len > MAX_SERVER_FILL_BYTES {
return Err(ProtoError::ParameterLengthTooLarge {
tag,
length: len as u64,
});
}
if input.len().saturating_sub(pos) < len {
return Err(ProtoError::MalformedParams);
}
@@ -231,14 +206,6 @@ mod tests {
assert_eq!(Params::decode(&params.encode()), Ok(params));
}
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(value.len() as u64, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
#[test]
fn params_round_trip() {
let params = Params {
@@ -258,7 +225,7 @@ mod tests {
}
#[test]
fn params_round_trip_negotiated_option_modes() {
fn params_round_trip_received_stats_modes() {
for received_stats in [
ReceivedStats::None,
ReceivedStats::Count,
@@ -271,7 +238,10 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_timestamp_modes() {
for stamp_at in [
StampAt::None,
StampAt::Send,
@@ -286,7 +256,10 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_clock_modes() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params {
protocol_version: 1,
@@ -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]
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
let params = Params {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-stats"
version = "0.3.0"
version = "0.2.0"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
+7 -16
View File
@@ -69,8 +69,8 @@ impl CoreStats {
StatsEvent::UniqueReply {
is_late, sample, ..
} => self.apply_unique_reply(is_late, *sample),
StatsEvent::DuplicateReply { bytes, .. } => {
self.apply_duplicate_reply(bytes);
StatsEvent::DuplicateReply { .. } => {
self.apply_duplicate_reply();
EventStatsUpdate::default()
}
StatsEvent::Loss { .. } => {
@@ -81,8 +81,8 @@ impl CoreStats {
self.apply_warning();
EventStatsUpdate::default()
}
StatsEvent::UntrackedLate { bytes, .. } => {
self.apply_untracked_late(bytes);
StatsEvent::UntrackedLate { .. } => {
self.apply_untracked_late();
EventStatsUpdate::default()
}
}
@@ -121,12 +121,7 @@ impl CoreStats {
.saturating_add(sample.bytes as u64);
if let Some(count) = sample.received_count {
let count = u64::from(count);
self.packets.server_packets_received = Some(
self.packets
.server_packets_received
.map_or(count, |current| current.max(count)),
);
self.packets.server_packets_received = Some(u64::from(count));
}
if let Some(window) = sample.received_window {
self.packets.server_received_window = Some(window);
@@ -186,11 +181,10 @@ impl CoreStats {
updates
}
fn apply_duplicate_reply(&mut self, bytes: usize) {
fn apply_duplicate_reply(&mut self) {
self.events.duplicate_replies += 1;
self.packets.packets_received += 1;
self.packets.duplicates += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
}
fn apply_loss(&mut self) {
@@ -201,11 +195,8 @@ impl CoreStats {
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.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 {
+8 -50
View File
@@ -48,8 +48,10 @@ impl IpdvTracker {
if let Some(pair) = self.try_pair(seq) {
pairs.push(pair);
}
if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
pairs.push(pair);
if let Some(next) = seq.checked_add(1) {
if let Some(pair) = self.try_pair(next) {
pairs.push(pair);
}
}
pairs
}
@@ -64,13 +66,15 @@ impl IpdvTracker {
};
if self.samples.remove(&seq).is_some() {
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> {
let previous_seq = current_seq.wrapping_sub(1);
let previous_seq = current_seq.checked_sub(1)?;
if !self.completed_pairs.insert(current_seq) {
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]
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
let mut tracker = IpdvTracker::new(None);
+236 -16
View File
@@ -3,7 +3,7 @@
//! The crate consumes `irtt-client` events and produces cumulative or rolling
//! snapshots for reporting and integration code.
#![forbid(unsafe_code)]
//#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(rustdoc::missing_crate_level_docs)]
@@ -198,9 +198,6 @@ pub struct EventCounts {
/// Warning events processed.
pub warning_events: u64,
/// Late replies that could not be matched to retained in-flight state.
///
/// These replies are observed on the socket, but cannot contribute RTT,
/// one-way delay, server-processing, or IPDV samples.
pub untracked_late_replies: u64,
}
@@ -209,26 +206,19 @@ pub struct EventCounts {
pub struct PacketCounts {
/// Probe packets sent by the local client.
pub packets_sent: u64,
/// Reply packets observed by the local client.
///
/// This includes unique measurable replies, duplicate replies, and late
/// replies that cannot be matched to retained in-flight state.
/// Reply packets received by the local client, including duplicates and late packets.
pub packets_received: u64,
/// Unique replies that can contribute timing measurements.
/// Unique replies received by the local client.
pub unique_replies: u64,
/// Duplicate replies received by the local client.
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,
/// Probe bytes sent by the local client.
pub bytes_sent: u64,
/// Reply bytes observed by the local client.
///
/// This includes bytes from unique measurable replies, duplicate replies,
/// and untracked-late replies. Per-category byte counts are not currently
/// exposed separately.
/// Reply bytes received by the local client.
pub bytes_received: u64,
/// Highest cumulative server-reported packets received, when available.
/// Server-reported packets received, when available.
pub server_packets_received: Option<u64>,
/// Server-reported receive window, when available.
pub server_received_window: Option<u64>,
@@ -271,3 +261,233 @@ pub struct ServerProcessingStats {
/// Time spent processing a probe at the server.
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!();
}
}
}
+9 -17
View File
@@ -22,7 +22,6 @@ pub(crate) enum StatsEvent {
},
DuplicateReply {
at: Instant,
bytes: usize,
},
Loss {
at: Instant,
@@ -32,7 +31,6 @@ pub(crate) enum StatsEvent {
},
UntrackedLate {
at: Instant,
bytes: usize,
},
}
@@ -98,17 +96,11 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
*bytes,
)),
}),
ClientEvent::LateReply {
received_at, bytes, ..
} => Some(StatsEvent::UntrackedLate {
ClientEvent::LateReply { received_at, .. } => Some(StatsEvent::UntrackedLate {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::DuplicateReply {
received_at, bytes, ..
} => Some(StatsEvent::DuplicateReply {
ClientEvent::DuplicateReply { received_at, .. } => Some(StatsEvent::DuplicateReply {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
@@ -123,10 +115,10 @@ impl StatsEvent {
match self {
Self::Sent { at, .. }
| Self::UniqueReply { at, .. }
| Self::DuplicateReply { at, .. }
| Self::DuplicateReply { at }
| Self::Loss { at }
| Self::Warning { at }
| Self::UntrackedLate { at, .. } => *at,
| Self::UntrackedLate { at } => *at,
}
}
}
@@ -156,17 +148,17 @@ impl ReplySample {
received_stats: Option<ReceivedStatsSample>,
bytes: usize,
) -> Self {
let rtt_primary_ns = signed_duration_ns(rtt.effective);
let rtt_primary_ns = signed_duration_ns(rtt.effective_signed);
Self {
bytes,
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
.and_then(|sample| sample.client_to_server)
.map(signed_duration_ns),
.map(duration_ns_i128),
receive_delay_ns: one_way
.and_then(|sample| sample.server_to_client)
.map(signed_duration_ns),
.map(duration_ns_i128),
server_processing_ns: server_timing
.and_then(|timing| timing.processing)
.map(duration_ns_i128),
@@ -203,7 +195,7 @@ fn duration_ns_i128(duration: Duration) -> i128 {
}
fn signed_duration_ns(duration: SignedDuration) -> i128 {
duration.as_nanos()
duration.ns
}
#[cfg(test)]
+18 -31
View File
@@ -12,21 +12,20 @@ pub fn ts(ms: u64) -> ClientTimestamp {
}
}
pub fn adjusted_rtt(raw_ms: u64, adjusted_ms: i128) -> RttSample {
let adjusted = SignedDuration::from_nanos(adjusted_ms * 1_000_000);
pub fn rtt(raw_ms: u64, effective_ms: i128) -> RttSample {
RttSample {
raw: Duration::from_millis(raw_ms),
adjusted: Some(adjusted),
effective: adjusted,
}
}
pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
let raw = Duration::from_millis(raw_ms);
RttSample {
raw,
adjusted: None,
effective: SignedDuration::from_duration(raw),
adjusted: u64::try_from(effective_ms).ok().map(Duration::from_millis),
effective: u64::try_from(effective_ms)
.ok()
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_millis(raw_ms)),
adjusted_signed: Some(SignedDuration {
ns: effective_ms * 1_000_000,
}),
effective_signed: SignedDuration {
ns: effective_ms * 1_000_000,
},
}
}
@@ -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 {
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 {
pub fn reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
let sent_at = ts(seq as u64 * 10);
let received_at = ClientTimestamp {
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(),
sent_at,
received_at,
rtt,
rtt: rtt(raw_ms, effective_ms),
server_timing: Some(ServerTiming {
receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000),
receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000),
@@ -72,8 +63,8 @@ fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> ClientEvent {
processing: Some(Duration::from_millis(1)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(1_000_000)),
server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
client_to_server: Some(Duration::from_millis(1)),
server_to_client: Some(Duration::from_millis(2)),
}),
received_stats: Some(ReceivedStatsSample {
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 {
late_reply_from(unadjusted_reply(seq, raw_ms))
}
fn late_reply_from(reply: ClientEvent) -> ClientEvent {
pub fn late_reply(seq: u32, raw_ms: u64, effective_ms: i128) -> ClientEvent {
let ClientEvent::EchoReply {
seq,
remote,
@@ -101,7 +88,7 @@ fn late_reply_from(reply: ClientEvent) -> ClientEvent {
bytes,
packet_meta,
..
} = reply
} = reply(seq, raw_ms, effective_ms)
else {
unreachable!();
};
+26 -148
View File
@@ -1,23 +1,20 @@
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 irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
};
use common::{late_reply, reply, sent, ts};
use irtt_client::{ClientEvent, PacketMeta};
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
#[test]
fn running_only_samples_avoid_finite_retention() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
collector.process(&unadjusted_reply(0, 10));
collector.process(&unadjusted_reply(1, 20));
collector.process(&reply(0, 10, 9));
collector.process(&reply(1, 20, 19));
let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.median_ns, None);
assert_eq!(snapshot.rtt.raw.median_ns, None);
assert_eq!(snapshot.rtt.adjusted.count, 0);
assert_eq!(snapshot.rtt.adjusted.median_ns, None);
assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None);
@@ -28,7 +25,7 @@ fn running_only_samples_avoid_finite_retention() {
fn continuous_mode_tracks_running_samples_without_exact_medians() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
for seq in 0..4104 {
collector.process(&unadjusted_reply(seq, 10));
collector.process(&reply(seq, 10, 10));
}
let snapshot = collector.snapshot();
@@ -41,24 +38,23 @@ fn continuous_mode_tracks_running_samples_without_exact_medians() {
#[test]
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&adjusted_reply(0, 1, -2));
collector.process(&adjusted_reply(1, 10, 8));
collector.process(&reply(0, 1, -2));
collector.process(&reply(1, 10, 8));
let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000));
assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0));
assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000);
assert_eq!(snapshot.rtt.adjusted.count, 2);
}
#[test]
fn late_unique_counts_and_duplicates_do_not_update_duplicate_measurements() {
fn late_unique_counts_and_duplicates_do_not_update_measurements() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&unadjusted_reply(1, 10));
collector.process(&unadjusted_late_reply(0, 20));
collector.process(&reply(1, 10, 9));
collector.process(&late_reply(0, 20, 19));
collector.process(&ClientEvent::DuplicateReply {
seq: 0,
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.duplicates, 1);
assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 64 + 64 + 64);
assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.loss.lost_packets, 0);
assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0);
@@ -88,7 +83,7 @@ fn final_loss_uses_sent_minus_unique_replies_not_echo_loss_events() {
sent_at: ts(0),
timeout_at: Instant::now(),
});
collector.process(&unadjusted_late_reply(0, 10));
collector.process(&late_reply(0, 10, 9));
let snapshot = collector.snapshot();
assert_eq!(snapshot.events.loss_events, 1);
@@ -115,40 +110,12 @@ fn assert_one_ipdv_pair(
pair
}
fn reply_at(seq: u32, raw_ms: u64, sent_ms: u64) -> ClientEvent {
let sent_at = ClientTimestamp {
mono: Instant::now() + Duration::from_millis(sent_ms),
wall: UNIX_EPOCH + Duration::from_millis(sent_ms),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(raw_ms),
wall: sent_at.wall + Duration::from_millis(raw_ms),
};
let raw = Duration::from_millis(raw_ms);
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw,
adjusted: None,
effective: SignedDuration::from_duration(raw),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn ipdv_is_sequence_adjacent_and_gap_preserving() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&unadjusted_reply(2, 15));
let adjacent = collector.process(&unadjusted_reply(3, 12));
let first = collector.process(&reply(0, 10, 10));
let gap = collector.process(&reply(2, 15, 15));
let adjacent = collector.process(&reply(3, 12, 12));
let snapshot = collector.snapshot();
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);
}
#[test]
fn ipdv_wraparound_sequence_is_adjacent() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&reply_at(u32::MAX, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 10));
assert!(first.contributed_sample);
assert_no_ipdv_pairs(&first);
assert!(wrapped.contributed_sample);
assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4));
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 1);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000);
}
#[test]
fn ipdv_wraparound_gap_is_preserved() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let before_wrap = collector.process(&reply_at(u32::MAX - 1, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 20));
assert!(before_wrap.contributed_sample);
assert_no_ipdv_pairs(&before_wrap);
assert!(wrapped.contributed_sample);
assert_no_ipdv_pairs(&wrapped);
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 0);
}
#[test]
fn late_reply_can_complete_ipdv_pair() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(1, 20));
let update = collector.process(&unadjusted_late_reply(0, 10));
collector.process(&reply(1, 20, 20));
let update = collector.process(&late_reply(0, 10, 10));
let snapshot = collector.snapshot();
@@ -214,8 +148,8 @@ fn late_reply_can_complete_ipdv_pair() {
#[test]
fn update_exposes_directional_ipdv_when_available() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(0, 10));
let update = collector.process(&unadjusted_reply(1, 13));
collector.process(&reply(0, 10, 10));
let update = collector.process(&reply(1, 13, 13));
assert!(update.contributed_sample);
@@ -228,9 +162,9 @@ fn update_exposes_directional_ipdv_when_available() {
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&unadjusted_reply(2, 20));
let fill = collector.process(&unadjusted_reply(1, 13));
let first = collector.process(&reply(0, 10, 10));
let gap = collector.process(&reply(2, 20, 20));
let fill = collector.process(&reply(1, 13, 13));
assert!(first.contributed_sample);
assert!(first.ipdv_pairs.is_empty());
@@ -257,7 +191,7 @@ fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
#[test]
fn server_processing_and_one_way_require_available_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(0, 10));
collector.process(&reply(0, 10, 9));
collector.process(&ClientEvent::LateReply {
seq: 9,
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.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.events.untracked_late_replies, 1);
assert_eq!(snapshot.packets.packets_received, 2);
assert_eq!(snapshot.packets.unique_replies, 1);
assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 128);
}
#[test]
fn negative_one_way_delay_samples_are_included_in_stats() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = ts(0);
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(10),
wall: sent_at.wall + Duration::from_millis(10),
};
collector.process(&ClientEvent::EchoReply {
seq: 0,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_millis(10),
adjusted: None,
effective: SignedDuration::from_nanos(10_000_000),
},
server_timing: None,
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(-1_000_000)),
server_to_client: Some(SignedDuration::from_nanos(-2_000_000)),
}),
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let snapshot = collector.snapshot();
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.one_way_delay.send_delay.min_ns, Some(-1_000_000));
assert_eq!(snapshot.one_way_delay.send_delay.total_ns, -1_000_000);
assert_eq!(snapshot.one_way_delay.receive_delay.count, 1);
assert_eq!(
snapshot.one_way_delay.receive_delay.min_ns,
Some(-2_000_000)
);
assert_eq!(snapshot.one_way_delay.receive_delay.total_ns, -2_000_000);
}
#[test]
fn older_cumulative_server_receive_count_does_not_regress() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(1, 10));
collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot();
assert_eq!(snapshot.packets.server_packets_received, Some(2));
}
#[test]
@@ -342,8 +220,8 @@ fn rolling_count_eviction_recomputes_from_bounded_events() {
rolling_time: None,
});
collector.process(&sent(0, ts(0)));
collector.process(&unadjusted_reply(0, 10));
collector.process(&unadjusted_reply(1, 20));
collector.process(&reply(0, 10, 10));
collector.process(&reply(1, 20, 20));
let rolling = collector.rolling_count().unwrap();
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());
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&unadjusted_reply(0, 10));
collector.process(&reply(0, 10, 10));
let loss = collector.snapshot().loss;
assert_eq!(loss.lost_packets, 1);
+1 -2
View File
@@ -4,11 +4,10 @@ members = ["cargo:."]
# Config for 'dist'
[dist]
# 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 = "github"
# The installers to generate for each app
installers = []
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
features = ["tui"]
-143
View File
@@ -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
-716
View File
@@ -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())