Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740594ff87 | ||
|
|
b9c2d024d9 | ||
|
|
7ee7474605 | ||
|
|
c18903b097 | ||
|
|
b397916ec6 | ||
|
|
9f6ef340f9 | ||
|
|
3a29b4efb2 | ||
|
|
a2f542bb97 | ||
|
|
5dd478e81a | ||
|
|
1ba4f61072 | ||
|
|
31fd76f78d | ||
|
|
344533f8b6 | ||
|
|
e8b3f64292 | ||
|
|
c98452aa2c | ||
|
|
807d8f2846 | ||
|
|
0cc6b1ea80 | ||
|
|
0ccca9b0ab | ||
|
|
df0d69e35d | ||
|
|
9ab1b78637 | ||
|
|
45351e0ba1 | ||
|
|
e2b70ac50a | ||
|
|
95037f7fa0 | ||
|
|
614579b8b6 | ||
|
|
46d7fdfe30 | ||
|
|
8602f73728 | ||
|
|
5d07853893 | ||
|
|
ccb31a59e7 | ||
|
|
56a2a653d0 | ||
|
|
1ec82d8c56 | ||
|
|
e9e1ff4218 | ||
|
|
2521959364 | ||
|
|
1754b77ea5 | ||
|
|
cab1fd1aab | ||
|
|
c9269ca0ee | ||
|
|
98e7231ede | ||
|
|
a0b51032f2 | ||
|
|
13a907bc28 | ||
|
|
1ce65c0db4 | ||
|
|
90178cfb75 | ||
|
|
4928661467 | ||
|
|
65eb494f7a | ||
|
|
08a9bc4e63 | ||
|
|
3c676edb38 | ||
|
|
e936d8147d | ||
|
|
d51e154783 | ||
|
|
02bad76676 | ||
|
|
0e23017100 | ||
|
|
d71503e4fd | ||
|
|
7bea3dd54e | ||
|
|
7129ee0bc5 | ||
|
|
75aaf68fea | ||
|
|
f470d4d7a6 | ||
|
|
2cdc084ef1 | ||
|
|
5c7d75e39e | ||
|
|
6a4dce68b3 | ||
|
|
0d9826800b | ||
|
|
2dee315a6e | ||
|
|
369e166a6c | ||
|
|
6df16e0205 | ||
|
|
7c190058f4 | ||
|
|
3e2cf3bad7 | ||
|
|
a055e80fd3 | ||
|
|
2634649852 | ||
|
|
b30cf9b3a4 | ||
|
|
5ce5f4fcda | ||
|
|
719073df1c | ||
|
|
9b70bd8b9d | ||
|
|
7b7cb6042e |
No files matched your search
@@ -7,7 +7,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -32,7 +31,7 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
components: rustfmt
|
||||
cache-key: fmt
|
||||
cache: false
|
||||
|
||||
- name: Check formatting
|
||||
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
||||
@@ -52,7 +51,7 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
components: clippy
|
||||
cache-key: clippy
|
||||
cache: false
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
@@ -79,46 +78,22 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: check
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
@@ -137,7 +112,7 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
cache: false
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
@@ -174,7 +149,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache-key: docs
|
||||
cache: false
|
||||
|
||||
- name: Check docs
|
||||
run: |
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Publish irtt-server image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# This repo tags each crate independently (irtt-proto/vX, irtt-client/vX,
|
||||
# ...); only the app package's tag corresponds to this image.
|
||||
- "irtt-rs/v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: irtt-server
|
||||
IMAGE_OWNER: lochnair
|
||||
GITEA_REGISTRY: git.lochnair.net
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
# Pinned so a floating "latest" release doesn't silently change the build.
|
||||
CRANE_VERSION: v0.20.2
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Determine image tags
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
version="${GITHUB_REF_NAME#irtt-rs/v}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${version},latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Manual dispatch off a branch: tag by commit only, never touch
|
||||
# "latest", so an ad-hoc build can't clobber the last release.
|
||||
version="sha-$(git rev-parse --short HEAD)"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Cross-compilation is handled inside the Dockerfile via tonistiigi/xx
|
||||
# (native builds, no emulation), but buildx still needs a driver that
|
||||
# can assemble a multi-platform manifest list.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
# The automatic per-job token comes back unauthorized for package
|
||||
# writes on this instance, so use a PAT (scope: write:package) instead.
|
||||
# (Secret names can't start with GITEA_/GITHUB_, hence REGISTRY_*.)
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
run: |
|
||||
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||
gitea_tags=""
|
||||
for t in "${tag_list[@]}"; do
|
||||
gitea_tags="${gitea_tags}${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t},"
|
||||
done
|
||||
echo "gitea=${gitea_tags%,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Gitea is the primary publish target: this is the only build. Every
|
||||
# other registry gets an exact digest copy of this same image (see
|
||||
# below), never a separate build, so the hash matches everywhere.
|
||||
- name: Build and push to Gitea
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.gitea }}
|
||||
|
||||
- name: Install crane
|
||||
run: |
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C "${HOME}/.local/bin" crane
|
||||
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Duplicate image to GHCR (same digest, no rebuild)
|
||||
run: |
|
||||
source_ref="${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
|
||||
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||
for t in "${tag_list[@]}"; do
|
||||
crane copy "${source_ref}" "${GHCR_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t}"
|
||||
done
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Proto Fuzz Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
||||
CARGO_FUZZ_VERSION: 0.13.1
|
||||
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
|
||||
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
|
||||
FUZZ_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
fuzz-smoke:
|
||||
name: cargo fuzz smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install stable Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Install nightly Rust toolchain
|
||||
# Installed alongside, but deliberately not made the directory
|
||||
# default: cargo-fuzz itself is built with the default stable
|
||||
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
||||
# the GNU-host nightly below. Building cargo-fuzz's own dependency
|
||||
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
|
||||
# unstable internal attributes on detecting a nightly compiler.
|
||||
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-fuzz
|
||||
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build all fuzz targets
|
||||
# libFuzzer sanitizers require dynamically linked GNU binaries; the
|
||||
# runner's default Rust host can be static musl.
|
||||
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Run bounded fuzz smoke for every target
|
||||
# A single job building once and running each target sequentially
|
||||
# avoids recompiling the fuzz workspace per target. Ten seconds per
|
||||
# target is enough to catch a broken harness or an obvious
|
||||
# regression; this is not meant to be exhaustive fuzzing.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
|
||||
echo "::group::fuzz smoke: ${target}"
|
||||
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||
-max_total_time=10 \
|
||||
-timeout=5
|
||||
echo "::endgroup::"
|
||||
done
|
||||
working-directory: fuzz
|
||||
@@ -32,13 +32,14 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache-key: interop-netem
|
||||
cache: false
|
||||
|
||||
- name: Check host tools
|
||||
- name: Check host tools and network administration access
|
||||
run: |
|
||||
ip -V
|
||||
tc -V
|
||||
go version
|
||||
sudo -n true
|
||||
|
||||
- name: Install upstream irtt
|
||||
run: |
|
||||
@@ -46,8 +47,8 @@ jobs:
|
||||
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: Build irtt-rs client
|
||||
run: cargo build -p irtt-rs --bin irtt-client --locked
|
||||
|
||||
- name: Run netem interop comparisons
|
||||
run: |
|
||||
@@ -55,21 +56,21 @@ jobs:
|
||||
--all \
|
||||
--skip-build \
|
||||
--upstream-irtt "$GOBIN/irtt" \
|
||||
--irtt-rs-command "$PWD/target/debug/irtt-cli"
|
||||
--irtt-rs-command "$PWD/target/debug/irtt-client"
|
||||
|
||||
- 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"
|
||||
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
|
||||
else
|
||||
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload netem interop artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v7
|
||||
with:
|
||||
name: interop-netem
|
||||
path: target/interop-netem
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Semver
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "irtt-proto/v*.*.*"
|
||||
- "irtt-client/v*.*.*"
|
||||
- "irtt-server/v*.*.*"
|
||||
- "irtt-stats/v*.*.*"
|
||||
- "irtt-rs/v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
|
||||
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
|
||||
|
||||
jobs:
|
||||
semver:
|
||||
name: cargo-semver-checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache: false
|
||||
|
||||
# The CLI preserves the previous wrapper's `feature-group: all-features`
|
||||
# behavior without its GitHub API/cache dependency.
|
||||
- name: Install cargo-semver-checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-semver-checks
|
||||
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Check semver compatibility
|
||||
run: cargo semver-checks check-release --all-features
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Supply chain
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "41 2 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
CARGO_DENY_VERSION: 0.20.2
|
||||
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
|
||||
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||
|
||||
jobs:
|
||||
cargo-deny:
|
||||
name: cargo-deny ${{ matrix.checks }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Advisories can appear after code was merged. Keep this visible,
|
||||
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||
- checks: advisories
|
||||
continue-on-error: true
|
||||
|
||||
# These are policy choices in this repo. They should block PRs.
|
||||
- checks: bans licenses sources
|
||||
continue-on-error: false
|
||||
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" --strip-components=1 \
|
||||
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
|
||||
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run cargo-deny
|
||||
run: >-
|
||||
cargo deny --manifest-path Cargo.toml --all-features check
|
||||
${{ matrix.checks }}
|
||||
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" actionlint
|
||||
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: actionlint handwritten workflows
|
||||
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
|
||||
# required absolute action URL in interop-netem.yml, so that workflow
|
||||
# is checked by the Gitea runner instead.
|
||||
run: |
|
||||
actionlint \
|
||||
.gitea/workflows/ci.yml \
|
||||
.gitea/workflows/docker-publish.yml \
|
||||
.gitea/workflows/fuzz.yml \
|
||||
.gitea/workflows/semver.yml \
|
||||
.gitea/workflows/supply-chain.yml \
|
||||
.github/workflows/actions-security.yml \
|
||||
.github/workflows/ci-arm.yml \
|
||||
.github/workflows/osv-scanner.yml \
|
||||
.github/workflows/rust-clippy.yml
|
||||
|
||||
- name: actionlint generated release workflow
|
||||
run: >-
|
||||
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
|
||||
@@ -16,34 +16,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
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/irtt-cli-release.yml
|
||||
shellcheck: false
|
||||
zizmor:
|
||||
name: zizmor
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +32,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
|
||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||
# both workflow directories through its GitHub Actions rule set.
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
persona: regular
|
||||
inputs: |
|
||||
.github/workflows
|
||||
.gitea/workflows
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Rust ARM CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: cargo ${{ matrix.task }} (${{ matrix.target }})
|
||||
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install musl toolchain
|
||||
if: matrix.musl
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
|
||||
- name: Run ${{ matrix.task }} without default features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
@@ -42,7 +42,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- 'irtt-cli**[0-9]+.[0-9]+.[0-9]+*'
|
||||
- 'irtt-rs**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
@@ -31,7 +31,7 @@ permissions:
|
||||
jobs:
|
||||
scan-scheduled:
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
./
|
||||
scan-pr:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Semver
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "irtt-proto/v*.*.*"
|
||||
- "irtt-client/v*.*.*"
|
||||
- "irtt-server/v*.*.*"
|
||||
- "irtt-stats/v*.*.*"
|
||||
- "irtt-cli/v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
semver:
|
||||
name: cargo-semver-checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check semver compatibility
|
||||
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2
|
||||
with:
|
||||
feature-group: all-features
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Supply chain
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
schedule:
|
||||
- cron: "41 2 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cargo-deny:
|
||||
name: cargo-deny ${{ matrix.checks }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Advisories can appear after code was merged. Keep this visible,
|
||||
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||
- checks: advisories
|
||||
continue-on-error: true
|
||||
|
||||
# These are policy choices in this repo. They should block PRs.
|
||||
- checks: bans licenses sources
|
||||
continue-on-error: false
|
||||
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
command-arguments: ${{ matrix.checks }}
|
||||
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
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/irtt-cli-release.yml
|
||||
shellcheck: false
|
||||
@@ -41,7 +41,7 @@ For compatibility questions, the verified spec and black-box observations define
|
||||
|
||||
Main boundary:
|
||||
|
||||
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
|
||||
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-rs` (application package, `crates/irtt-app`) — presentation/orchestration.
|
||||
|
||||
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
|
||||
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
|
||||
@@ -51,7 +51,7 @@ Main boundary:
|
||||
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
||||
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
||||
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
||||
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
||||
- `irtt-rs` (application package, `crates/irtt-app`): argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Produces the `irtt-rs` multicall dispatcher plus the dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Do not duplicate protocol/session state machines here.
|
||||
|
||||
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
|
||||
|
||||
@@ -66,7 +66,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
||||
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
|
||||
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
|
||||
- The server applet exists behind the `irtt-rs` application package's `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
|
||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
||||
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||
|
||||
@@ -82,42 +82,44 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
||||
|
||||
## Testing policy
|
||||
|
||||
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage.
|
||||
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage. Tests are maintained repository code: every persistent test adds review, CI, refactoring, and maintenance cost, so it must justify that cost.
|
||||
|
||||
### Prefer behavior over internals
|
||||
### Test at the owning layer
|
||||
|
||||
For client, managed-runtime, blocking, CLI, and TUI behavior, prefer normal-interface component/integration tests: real UDP/session exchange, emitted events/outcomes, negotiated params, multi-target lifecycle, process exit status, and user-visible diagnostics.
|
||||
Prefer production interfaces and component/integration tests at the lowest architectural layer that owns the complete behavior. Higher-level or process-level is not inherently better.
|
||||
|
||||
One strong behavioral test is usually better than several tests of private helpers that reconstruct the same behavior.
|
||||
- `irtt-proto`: wire formats, codecs, validation, malformed input, and protocol boundaries.
|
||||
- `irtt-client`: client/session behavior, multi-target orchestration, burst/staggered pacing, scheduling, lifecycle, managed events, and target updates.
|
||||
- `irtt-server`: server/session behavior and server runtime/orchestration.
|
||||
- `irtt-stats`: statistics, loss/IPDV accounting, rolling windows, and retention bounds.
|
||||
- `irtt-rs` / `irtt-app`: behavior introduced by the application layer itself, such as argument-to-config mapping, presentation/formatting, app-specific diagnostics/exit policy, and genuine process/signal behavior.
|
||||
|
||||
Do not add persistent tests merely because implementation code changed. Avoid tests that primarily:
|
||||
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
|
||||
|
||||
- assert private fields, cursors, queue shapes, or exact internal representation;
|
||||
- mirror implementation steps line-for-line;
|
||||
- exercise branches/helpers solely for coverage;
|
||||
### Every persistent test must earn its place
|
||||
|
||||
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
|
||||
|
||||
One strong behavioral test through a normal production interface is usually better than several tests of private helpers or reconstructed internals. Be especially skeptical of unit tests that depend on private functions, methods, structs, fields, or implementation sequence: harmless internal refactors should not require test rewrites.
|
||||
|
||||
Avoid tests that primarily:
|
||||
|
||||
- assert private representation or historical implementation details;
|
||||
- mirror implementation steps or exercise branches solely for coverage;
|
||||
- duplicate stronger coverage at another layer;
|
||||
- pin incidental scheduling/ordering that is not contractual;
|
||||
- pin exact human-readable wording unless that wording is itself contractual;
|
||||
- test standard-library/dependency behavior;
|
||||
- require production test-only observability for a fact naturally visible through packets/events/outcomes.
|
||||
- use subprocess machinery or test-only observability when the owning crate exposes the behavior directly.
|
||||
|
||||
A harmless private refactor should not require widespread test rewrites unless a deliberately protected invariant changed.
|
||||
If a component/integration test through the owning crate's normal API protects the same durable behavior, prefer it unless a focused unit test materially improves precision, diagnosis, or coverage of a hard-to-observe invariant.
|
||||
|
||||
### Unit tests where they earn it
|
||||
|
||||
Focused unit/table-driven tests are encouraged for stable pure or algorithmic behavior, especially:
|
||||
|
||||
- `irtt-proto` layouts, codecs, vectors, HMAC, parsing, malformed input, and boundaries;
|
||||
- `irtt-stats` formulas, loss accounting, IPDV, rolling windows, and bounded state;
|
||||
- hand-rolled data structures and subtle pure invariants;
|
||||
- resource/liveness guarantees that cannot be observed reliably from a higher layer.
|
||||
|
||||
If a unit test and a normal-interface test prove the same durable behavior, prefer the normal-interface test unless the unit test materially improves precision or diagnosis.
|
||||
Focused unit/table-driven tests are appropriate for stable pure or algorithmic behavior, especially protocol codecs/vectors, statistics formulas, hand-rolled data structures, and resource/liveness invariants that cannot be observed reliably from a higher-level production interface.
|
||||
|
||||
### Temporary implementation tests
|
||||
|
||||
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition.
|
||||
|
||||
Before finalizing, remove or consolidate them unless they independently protect a durable regression or invariant. A test being useful during development does not by itself justify permanent repository code.
|
||||
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition. Remove or consolidate them before finalizing unless they independently protect a durable regression or invariant.
|
||||
|
||||
### Resource/liveness tests and hooks
|
||||
|
||||
@@ -192,7 +194,7 @@ Report concisely:
|
||||
|
||||
1. what changed;
|
||||
2. verification actually run and its result;
|
||||
3. meaningful tests added/updated/consolidated/removed;
|
||||
3. meaningful tests added/updated/consolidated/removed, with justification for each persistent test added or materially expanded;
|
||||
4. remaining risks, ambiguities, or follow-ups.
|
||||
|
||||
Do not produce a giant changelog of incidental edits.
|
||||
+66
-3
@@ -28,6 +28,12 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-client
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
@@ -50,6 +56,18 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-server
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Changed
|
||||
|
||||
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- New public `address_family_available()` helper (re-exported from `set`), for probing whether an address family can actually bind locally rather than merely constructing a socket for it. Used by the `irtt-rs` server applet to distinguish a genuinely unsupported address family from an ordinary bind failure when falling back from its default dual-family bind.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
@@ -88,6 +106,49 @@ release. The format is loosely based on
|
||||
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
|
||||
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
|
||||
|
||||
## irtt-rs
|
||||
|
||||
### 0.7.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
|
||||
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
|
||||
|
||||
#### Changed
|
||||
|
||||
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
|
||||
|
||||
### 0.6.2
|
||||
|
||||
#### Added
|
||||
|
||||
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
|
||||
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
|
||||
|
||||
#### Changed
|
||||
|
||||
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
|
||||
|
||||
### 0.6.1
|
||||
|
||||
#### Added
|
||||
|
||||
- The `irtt-server` applet now has sensible zero-argument defaults: with no `--bind`, it binds the wildcard IRTT port on both address families (`[::]:2112` then `0.0.0.0:2112`) instead of requiring an explicit address, on platforms where wildcard reply-source selection is supported. Any explicit `--bind` fully replaces the default pair rather than adding to it.
|
||||
|
||||
#### Changed
|
||||
|
||||
- If one of the two default listeners fails because its address family has no local support on the host (e.g. IPv6 administratively disabled on an otherwise IPv4-capable host), the server now falls back to serving just the surviving family instead of failing outright. Any other failure reason (port in use, permission denied, no safe wildcard reply-source path) still fails startup as it would for an explicit `--bind`.
|
||||
|
||||
### 0.6.0
|
||||
|
||||
#### Changed
|
||||
|
||||
- The application Cargo package was renamed from `irtt-cli` to `irtt-rs`, moving to `crates/irtt-app`. This is the application distribution package that produces the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries; it is unrelated to the reusable `irtt-client` library crate, which keeps its existing name and version.
|
||||
- The `irtt-cli` executable was hard-renamed to `irtt-client` to avoid reading as an umbrella CLI now that the application also ships a first-class server. There is no `irtt-cli` compatibility binary or alias.
|
||||
- Dedicated binaries now have role-specific entry points (`irtt-client`, `irtt-tui`, `irtt-server`) instead of all funneling through the multicall dispatcher's argv0/subcommand logic. Each dedicated binary always runs its own role regardless of the name it is invoked or copied under, and no longer links code for the other applet roles it doesn't implement. The `irtt-rs` binary remains the feature-selectable multicall dispatcher and is the only binary that inspects argv0 or subcommands.
|
||||
- Future application releases move to the `irtt-rs/vX.Y.Z` tag namespace (previously `irtt-cli/vX.Y.Z`); see [Releasing](#releasing).
|
||||
|
||||
## irtt-cli
|
||||
|
||||
### 0.5.0
|
||||
@@ -125,6 +186,8 @@ rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
|
||||
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
|
||||
libraries published to [crates.io](https://crates.io); their tags exist to
|
||||
identify the exact source for a given crates.io release and do not produce a
|
||||
GitHub Release. `irtt-cli` is the only crate with prebuilt binary artifacts:
|
||||
pushing an `irtt-cli/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
|
||||
to build and publish a GitHub Release with platform binaries.
|
||||
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
|
||||
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
|
||||
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
|
||||
to build and publish a GitHub Release with platform binaries. The historical
|
||||
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
|
||||
Generated
+210
-54
@@ -109,7 +109,16 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.6.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -118,6 +127,12 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -227,7 +242,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -370,9 +385,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
@@ -380,26 +395,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -481,9 +496,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -516,10 +531,16 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-set 0.5.3",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -689,31 +710,17 @@ dependencies = [
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-cli"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"irtt-stats",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"nix 0.31.3",
|
||||
"proptest",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
@@ -735,13 +742,29 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"md-5",
|
||||
"proptest",
|
||||
"subtle",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"irtt-stats",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"irtt-proto",
|
||||
@@ -756,6 +779,7 @@ name = "irtt-stats"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"irtt-client",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -857,15 +881,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
@@ -1094,9 +1118,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
|
||||
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -1104,9 +1128,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
|
||||
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1114,9 +1138,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
|
||||
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1127,9 +1151,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
|
||||
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -1161,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1204,6 +1228,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -1213,6 +1246,31 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.1",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -1236,11 +1294,31 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.7"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1249,6 +1327,24 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.2"
|
||||
@@ -1416,6 +1512,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -1461,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1597,15 +1705,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termina"
|
||||
version = "0.3.3"
|
||||
@@ -1719,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1765,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1780,6 +1901,12 @@ version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1817,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1842,6 +1969,15 @@ dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2016,3 +2152,23 @@ name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
+3
-6
@@ -4,7 +4,7 @@ members = [
|
||||
"crates/irtt-client",
|
||||
"crates/irtt-server",
|
||||
"crates/irtt-stats",
|
||||
"crates/irtt-cli",
|
||||
"crates/irtt-app",
|
||||
"crates/irtt-integration-tests",
|
||||
]
|
||||
resolver = "2"
|
||||
@@ -15,22 +15,19 @@ license = "Apache-2.0"
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/Lochnair/irtt-rs"
|
||||
homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
readme = "README.md"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.5.0", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.0", path = "crates/irtt-server" }
|
||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
# The profile that 'dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Builds the irtt-server applet only, statically linked against musl, and
|
||||
# ships it in a scratch image with no shell or libc.
|
||||
#
|
||||
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
|
||||
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
|
||||
# picks the right musl target triple, sysroot, and linker for whatever
|
||||
# platform buildx asked for.
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
|
||||
|
||||
COPY --from=xx / /
|
||||
|
||||
RUN apk add --no-cache clang lld file
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
RUN xx-apk add --no-cache musl-dev
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
|
||||
RUN xx-cargo build \
|
||||
--release \
|
||||
--locked \
|
||||
-p irtt-rs \
|
||||
--bin irtt-server \
|
||||
--no-default-features \
|
||||
--features server \
|
||||
--target-dir /build/target \
|
||||
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
|
||||
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /irtt-server /irtt-server
|
||||
|
||||
ENTRYPOINT ["/irtt-server"]
|
||||
@@ -1,361 +1,179 @@
|
||||
# irtt-rs
|
||||
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server.
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
|
||||
finite or continuous UDP latency probing, plus the server side those probes
|
||||
run against.
|
||||
|
||||
It provides command-line applets and reusable Rust libraries for finite or continuous latency probing, plus the server side those probes can run against. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.
|
||||
|
||||
This is not the upstream IRTT project. For the original implementation, protocol background, and broader documentation, see [heistp/irtt](https://github.com/heistp/irtt).
|
||||
This is not the upstream IRTT project. For the original implementation,
|
||||
protocol background, and broader documentation, see
|
||||
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
|
||||
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
|
||||
mistaken for it.
|
||||
|
||||
## Features
|
||||
|
||||
* Finite and continuous probe sessions
|
||||
* Concurrent probing of multiple targets
|
||||
* Reusable client library with a structured event stream
|
||||
* Human-readable table output
|
||||
* CSV, TSV, and JSON Lines output for scripts and monitoring systems
|
||||
* Selectable output columns
|
||||
* Automatic final summaries for eligible table-output runs
|
||||
* Concurrent probing of multiple targets, with staggered or burst pacing
|
||||
* Reusable client library with a structured event stream (blocking, Tokio,
|
||||
or managed)
|
||||
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
|
||||
monitoring systems, with selectable output columns
|
||||
* Optional terminal UI
|
||||
* Reusable Tokio-native server library
|
||||
* UDP server applet with explicit bind and session policy options
|
||||
* UDP server applet with sensible zero-argument defaults and explicit bind
|
||||
and session-policy options
|
||||
* Router-friendly multi-applet binary layout
|
||||
|
||||
The installed dispatcher is named `irtt-rs` rather than `irtt` to avoid conflicting with or being mistaken for upstream IRTT.
|
||||
## Comparison with upstream IRTT
|
||||
|
||||
`irtt-rs` is an independent, compatible reimplementation, not a successor or
|
||||
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
|
||||
remains the original implementation. Interoperability is a project goal; see
|
||||
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
|
||||
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
|
||||
methodology and protocol baseline this project verifies against. Choose
|
||||
between them based on the capabilities and deployment model you need.
|
||||
|
||||
The table below was checked against upstream release
|
||||
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
|
||||
This project's own interop testing and protocol baseline are currently
|
||||
pinned to the prior release, v0.9.1; the wire protocol version number is
|
||||
unchanged between the two, but v0.9.2 has not itself been exercised against
|
||||
this project's interop harness.
|
||||
|
||||
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
|
||||
| --- | --- | --- |
|
||||
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
|
||||
| Finite probing | Supported, default 10s | Supported, default 1m |
|
||||
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
|
||||
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
|
||||
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
|
||||
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
|
||||
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
|
||||
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
|
||||
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
|
||||
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
|
||||
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
|
||||
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
|
||||
|
||||
Upstream has capabilities this project does not implement: a `report`
|
||||
subcommand for replaying saved JSON results, benchmarking and
|
||||
timer/clock-diagnostic subcommands, client-side payload fill and
|
||||
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
|
||||
server-side fill policy (this project's server always honors a session's
|
||||
negotiated fill request, with a fixed fallback pattern, but has no equivalent
|
||||
to upstream's flags for restricting or overriding it), syslog logging, and
|
||||
bundled systemd/OpenRC service files.
|
||||
|
||||
This project has capabilities upstream does not implement: multi-target
|
||||
probing with labels, the terminal UI, CSV/TSV output with selectable
|
||||
columns, a quantified and actively-warned memory-retention model for
|
||||
long-running client sessions, and best-effort Linux kernel-timestamp
|
||||
preference for its own send/receive endpoint.
|
||||
|
||||
## Installation
|
||||
|
||||
The build requires Rust 1.88 or newer.
|
||||
|
||||
From a local checkout:
|
||||
Install the latest release from crates.io:
|
||||
|
||||
```sh
|
||||
cargo install irtt-rs --locked
|
||||
```
|
||||
|
||||
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
|
||||
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
|
||||
isolation with `--no-default-features` plus the applet's own feature, for
|
||||
example a lightweight client-only install:
|
||||
|
||||
```sh
|
||||
cargo install irtt-rs --locked --no-default-features --features client
|
||||
```
|
||||
|
||||
For development, or to install directly from a checkout:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Lochnair/irtt-rs.git
|
||||
cd irtt-rs
|
||||
cargo install --path crates/irtt-cli
|
||||
```
|
||||
|
||||
This installs:
|
||||
|
||||
* `irtt-rs`, the canonical multi-applet dispatcher
|
||||
* `irtt-cli`, the stream and text client
|
||||
* `irtt-server`, the UDP server
|
||||
|
||||
To also install the optional terminal UI:
|
||||
|
||||
```sh
|
||||
cargo install --path crates/irtt-cli --features tui
|
||||
cargo install --path crates/irtt-app --locked
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Probe a server using the default settings:
|
||||
Probe a server:
|
||||
|
||||
```sh
|
||||
irtt-cli netperf-eu.bufferbloat.net:2112
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||
```
|
||||
|
||||
Set the test duration and probe interval:
|
||||
Probe continuously until interrupted (`Ctrl-C`):
|
||||
|
||||
```sh
|
||||
irtt-cli netperf-eu.bufferbloat.net:2112 \
|
||||
--duration 30s \
|
||||
--interval 100ms
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
||||
```
|
||||
|
||||
Run continuously until interrupted:
|
||||
Watch the same session live in the terminal UI:
|
||||
|
||||
```sh
|
||||
irtt-cli netperf-eu.bufferbloat.net:2112 --duration 0
|
||||
irtt-tui netperf-eu.bufferbloat.net:2112
|
||||
```
|
||||
|
||||
Use `Ctrl-C` to stop gracefully.
|
||||
|
||||
## Multiple targets
|
||||
|
||||
Probe several targets concurrently:
|
||||
Run a server with sensible defaults — no `--bind` needed:
|
||||
|
||||
```sh
|
||||
irtt-cli host-a:2112 host-b:2112
|
||||
irtt-server
|
||||
```
|
||||
|
||||
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix assigns the logical
|
||||
target name used in output and the TUI; without it, the target string itself is used. Assign
|
||||
stable labels to targets:
|
||||
## Documentation
|
||||
|
||||
```sh
|
||||
irtt-cli ams=ams.example.com:2112 sg=sg.example.com:2112
|
||||
```
|
||||
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
|
||||
formats and columns, measurement field definitions, finite/continuous
|
||||
memory behavior, kernel timestamps.
|
||||
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
|
||||
retained history.
|
||||
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
|
||||
overrides, wildcard listener behavior, session policy, HMAC.
|
||||
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
|
||||
binary names, feature-gated builds.
|
||||
* [docs.rs](https://docs.rs) for the library crates below.
|
||||
* [`docs/protocol/`](docs/protocol/) and
|
||||
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
|
||||
protocol specification and interoperability validation against upstream
|
||||
`irtt`.
|
||||
|
||||
Labeled and unlabeled targets can be freely mixed in one argument list:
|
||||
Every applet also documents its full option list with `--help`.
|
||||
|
||||
```sh
|
||||
irtt-cli host-a:2112 ams=ams.example.com:2112
|
||||
```
|
||||
## Crates
|
||||
|
||||
Multi-target sessions use staggered pacing by default. To send each target's probes together:
|
||||
| Crate | crates.io | What it is |
|
||||
| --- | --- | --- |
|
||||
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
|
||||
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
|
||||
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
|
||||
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
|
||||
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
|
||||
|
||||
```sh
|
||||
irtt-cli host-a:2112 host-b:2112 --pacing burst
|
||||
```
|
||||
|
||||
Target labels are included in the default multi-target output.
|
||||
|
||||
## Output formats
|
||||
|
||||
The CLI supports four event-row formats:
|
||||
|
||||
* `table`: human-readable terminal output
|
||||
* `csv`: comma-separated output
|
||||
* `tsv`: tab-separated output
|
||||
* `jsonl`: one JSON object per event
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --format table
|
||||
irtt-cli <server> --format jsonl
|
||||
irtt-cli <server> --format csv \
|
||||
--columns event,seq,remote,effective_rtt_us
|
||||
```
|
||||
|
||||
For a stream containing only effective RTT values in microseconds:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> \
|
||||
--format tsv \
|
||||
--columns effective_rtt_us \
|
||||
--header never
|
||||
```
|
||||
|
||||
List all available columns with:
|
||||
|
||||
```sh
|
||||
irtt-cli --list-columns
|
||||
```
|
||||
|
||||
Useful measurement fields include:
|
||||
|
||||
* `raw_rtt_us`: client-observed send-to-receive RTT
|
||||
* `adjusted_rtt_us`: RTT adjusted for server processing when available
|
||||
* `effective_rtt_us`: adjusted RTT when available, otherwise raw RTT
|
||||
* `sd_us` and `rd_us`: send and receive one-way delay estimates
|
||||
* `ipdv_us`: inter-packet delay variation
|
||||
* `server_processing_us`: time spent processing the packet at the server
|
||||
|
||||
Adjusted RTT can be negative when server processing exceeds the measured raw RTT. One-way delay estimates can be negative because of clock skew between the client and server.
|
||||
|
||||
Default table output prints a final summary after completed finite runs and interrupted continuous runs when the run policy permits it. CSV, TSV, and JSON Lines output do not print this summary.
|
||||
|
||||
A peer closing a finite CLI or TUI run is accepted as a terminal outcome. In
|
||||
continuous mode, peer closure exits nonzero unless the user requested
|
||||
interruption, allowing a supervisor to restart the client.
|
||||
|
||||
## Terminal UI
|
||||
|
||||
When built with the `tui` feature, `irtt-tui` provides a live cumulative dashboard:
|
||||
|
||||
```sh
|
||||
irtt-tui <server>
|
||||
```
|
||||
|
||||
It runs continuously by default. A finite duration can be selected explicitly:
|
||||
|
||||
```sh
|
||||
irtt-tui <server> --duration 30s
|
||||
```
|
||||
|
||||
Multiple targets and pacing options work the same way as in `irtt-cli`:
|
||||
|
||||
```sh
|
||||
irtt-tui host-a:2112 host-b:2112 --pacing burst
|
||||
```
|
||||
|
||||
Quit with `q` or `Ctrl-C`.
|
||||
|
||||
## Server
|
||||
|
||||
The server applet requires at least one explicit bind address:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 127.0.0.1:2112
|
||||
```
|
||||
|
||||
The same applet is reachable through the dispatcher:
|
||||
|
||||
```sh
|
||||
irtt-rs server --bind 192.0.2.10:2112
|
||||
```
|
||||
|
||||
Repeat `--bind` to serve several addresses from one process, in the order given:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 0.0.0.0:2112 \
|
||||
--bind [::]:2112
|
||||
```
|
||||
|
||||
Every invocation runs through the same multi-listener path, so one address is an
|
||||
ordinary set of one rather than a separate mode. The policy options below are
|
||||
applied to every listener, but the listeners are otherwise independent: each has
|
||||
its own sessions and tokens, so a session belongs to the address it was opened
|
||||
on and `--max-sessions` bounds each listener rather than the process. Binding is
|
||||
all or nothing — if any address cannot be bound, none are served — and a port of
|
||||
`0` selects an unused port per listener. If a listener fails while running, the
|
||||
others are shut down with it rather than leaving a service configured for two
|
||||
families answering on one.
|
||||
|
||||
An explicit interface address works on every supported system. A wildcard bind
|
||||
such as `--bind 0.0.0.0:2112` reads each request's local destination from packet
|
||||
metadata and sends that request's reply from the same address, so a client on a
|
||||
multi-homed host is answered from the endpoint it contacted. That is implemented
|
||||
on Linux, macOS and FreeBSD; elsewhere a wildcard bind is refused rather than
|
||||
served from a routing-table source address a client would discard. Wildcard
|
||||
IPv4 and IPv6 listeners may share one port, as above.
|
||||
|
||||
Each bound endpoint is printed on startup once every listener is up, which also
|
||||
resolves a port of `0`. `Ctrl-C` stops the server gracefully.
|
||||
|
||||
Session policy is set with options that map directly onto the server library's
|
||||
configuration; anything left unset keeps the library default:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 192.0.2.10:2112 \
|
||||
--max-sessions 512 \
|
||||
--idle-timeout 30s
|
||||
```
|
||||
|
||||
Two optional controls restrict what the server will provide a session, and both
|
||||
are off unless asked for:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 192.0.2.10:2112 \
|
||||
--timestamp-allowance single \
|
||||
--no-dscp
|
||||
```
|
||||
|
||||
`--timestamp-allowance` takes `dual` (the default, honoring every requested
|
||||
placement), `single`, which provides at most one timestamp instant and answers a
|
||||
request for both receive and send timestamps with the midpoint, or `none`, which
|
||||
provides no timestamps. The requested clock source is never changed, so a single
|
||||
instant is still reported once per requested clock. `--no-dscp` negotiates any
|
||||
requested DSCP to zero, so clients are told their echo replies will be unmarked,
|
||||
and they are sent unmarked.
|
||||
|
||||
To require authentication, pass the shared key both sides use:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
||||
```
|
||||
|
||||
For the full option list:
|
||||
|
||||
```sh
|
||||
irtt-server --help
|
||||
```
|
||||
|
||||
## Library usage
|
||||
|
||||
`irtt-client` exposes the client session and event layer independently of CLI formatting and statistics.
|
||||
|
||||
Add it from a local checkout:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
irtt-client = { path = "crates/irtt-client", features = ["tokio"] }
|
||||
```
|
||||
|
||||
Minimal managed-client example for a synchronous frontend:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy,
|
||||
ManagedTargetConfig, TargetId,
|
||||
};
|
||||
use irtt_client::ClientConfig;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: Some(Duration::from_secs(10)),
|
||||
interval: Duration::from_secs(1),
|
||||
..ClientConfig::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
let targets = vec![ManagedTargetConfig::new(
|
||||
TargetId::from("edge"),
|
||||
"netperf-eu.bufferbloat.net:2112",
|
||||
)];
|
||||
let owner = BlockingManagedClient::start(config, targets)?;
|
||||
let outcome = owner.join()?;
|
||||
println!("session ended: {:?}", outcome.end_reason);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
For presentation events, use `BlockingManagedClient::start_with_subscription`,
|
||||
`ManagedEvent`, and `ManagedEventTryRecvError`, while consulting the durable
|
||||
`handle.status()` and final outcome for authoritative state. Presentation
|
||||
events are bounded and lossy, not reliable history. Dynamic desired-target
|
||||
updates are available through `ManagedClientHandle::update_targets`.
|
||||
|
||||
`BlockingManagedClient` owns a dedicated Tokio runtime and thread for
|
||||
synchronous callers. Callers that already own Tokio can drive
|
||||
`ManagedClientTask` directly.
|
||||
Each library crate has its own crates.io README describing what it's for and
|
||||
when to use it instead of `irtt-client`/`irtt-server` directly.
|
||||
|
||||
## Binaries and features
|
||||
|
||||
| Build | Binaries |
|
||||
| ----------------------------------------- | ------------------------------------------------- |
|
||||
| --- | --- |
|
||||
| `--no-default-features` | `irtt-rs` |
|
||||
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
|
||||
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
||||
| Default features | `irtt-rs`, `irtt-cli`, `irtt-server` |
|
||||
| `--features tui` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
||||
| `--all-features` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
||||
|
||||
`irtt-cli` requires the `client` feature, `irtt-server` the `server` feature,
|
||||
and `irtt-tui` the `tui` feature. The `irtt-rs` dispatcher is always built, and
|
||||
reports which applets the build actually contains.
|
||||
|
||||
For available command-line options:
|
||||
|
||||
```sh
|
||||
irtt-cli --help
|
||||
irtt-server --help
|
||||
irtt-tui --help
|
||||
```
|
||||
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
|
||||
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
|
||||
|
||||
## Development
|
||||
|
||||
Common verification commands:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo build -p irtt-cli --all-features --release
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
```
|
||||
|
||||
## Project status
|
||||
|
||||
The client, event stream, machine-readable output, multi-target execution, local statistics, and optional TUI are implemented.
|
||||
|
||||
The server library and the server applet are implemented: open negotiation,
|
||||
session state, echo processing, per-session rate limiting, idle expiry, the
|
||||
maximum-duration close, HMAC authentication, the negotiated per-session reply
|
||||
traffic class, which is applied on sockets that support it, server fill,
|
||||
wildcard reply-source selection on Linux, macOS and FreeBSD, the optional
|
||||
timestamp and DSCP restriction controls, and serving several listeners from one
|
||||
process with independent sessions per listener.
|
||||
|
||||
Server replies fill their payload with the negotiated ServerFill mode: `none`,
|
||||
which is zero-filled, `rand`, or `pattern:` with a repeating hexadecimal
|
||||
pattern. Every valid descriptor is honored; a descriptor the server cannot
|
||||
parse, and a client that expresses no fill preference at all, get the default
|
||||
`pattern:69727474`.
|
||||
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
|
||||
clean-room provenance boundary this project maintains against upstream
|
||||
`irtt`.
|
||||
@@ -1,17 +1,18 @@
|
||||
[package]
|
||||
name = "irtt-cli"
|
||||
version = "0.5.0"
|
||||
description = "Command-line IRTT-compatible client and server"
|
||||
name = "irtt-rs"
|
||||
version = "0.7.0"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
categories = ["command-line-utilities", "network-programming"]
|
||||
|
||||
[lib]
|
||||
name = "irtt_app"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
@@ -19,9 +20,12 @@ name = "irtt-rs"
|
||||
path = "src/bin/irtt-rs.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-cli"
|
||||
path = "src/bin/irtt-cli.rs"
|
||||
name = "irtt-client"
|
||||
path = "src/bin/irtt-client.rs"
|
||||
required-features = ["client"]
|
||||
# This applet shares its name with the irtt-client library crate, whose rustdoc
|
||||
# output it would otherwise collide with.
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-tui"
|
||||
@@ -43,9 +47,9 @@ server = ["dep:irtt-server", "dep:tokio"]
|
||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.29.0", optional = true }
|
||||
ctrlc = "3.5.2"
|
||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
@@ -56,3 +60,9 @@ tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,33 @@
|
||||
# irtt-rs
|
||||
|
||||
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
|
||||
package `irtt-rs`.
|
||||
|
||||
Installing this crate builds:
|
||||
|
||||
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
|
||||
server applet by subcommand.
|
||||
- `irtt-client` — the dedicated client binary (feature `client`).
|
||||
- `irtt-tui` — the interactive terminal UI (feature `tui`).
|
||||
- `irtt-server` — the dedicated server binary (feature `server`).
|
||||
|
||||
All three features are enabled by default, so a plain `cargo install irtt-rs`
|
||||
installs everything.
|
||||
|
||||
## This is an application, not a library
|
||||
|
||||
This package's library target is undocumented (`doc = false`) and exists only
|
||||
to share code between its binaries. If you're looking for the client/session
|
||||
or server API to embed in your own Rust program, use
|
||||
[`irtt-client`](https://crates.io/crates/irtt-client) or
|
||||
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
|
||||
|
||||
## Usage
|
||||
|
||||
See the [project repository](https://github.com/Lochnair/irtt-rs) for
|
||||
installation instructions, command-line usage, and configuration.
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -20,7 +20,7 @@ pub enum AppletDispatch {
|
||||
|
||||
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
||||
match applet_basename(argv0) {
|
||||
"irtt-cli" => Some(RequestedApplet::Client),
|
||||
"irtt-client" => Some(RequestedApplet::Client),
|
||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
||||
"irtt-rs" => None,
|
||||
@@ -58,7 +58,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
|
||||
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||
"client" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: applet_argv("irtt-cli", &argv[2..]),
|
||||
argv: applet_argv("irtt-client", &argv[2..]),
|
||||
}),
|
||||
"tui" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
@@ -111,7 +111,7 @@ fn server_applet_about() -> &'static str {
|
||||
}
|
||||
|
||||
fn known_applet_binary_names() -> &'static str {
|
||||
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
|
||||
"irtt-rs, irtt-client, irtt-tui, irtt-server"
|
||||
}
|
||||
|
||||
fn applet_command_names() -> &'static str {
|
||||
@@ -119,11 +119,11 @@ fn applet_command_names() -> &'static str {
|
||||
}
|
||||
|
||||
fn dispatcher_after_help() -> String {
|
||||
let mut help = "Recognized applet binary names: irtt-cli, irtt-tui, irtt-server".to_owned();
|
||||
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !cfg!(feature = "client") {
|
||||
missing.push("irtt-cli requires the client feature");
|
||||
missing.push("irtt-client requires the client feature");
|
||||
}
|
||||
if !cfg!(feature = "server") {
|
||||
missing.push("irtt-server requires the server feature");
|
||||
@@ -188,7 +188,7 @@ mod tests {
|
||||
#[test]
|
||||
fn applet_detection_maps_binary_names() {
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("/usr/bin/irtt-cli"),
|
||||
detect_applet_from_argv0("/usr/bin/irtt-client"),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -203,6 +203,12 @@ mod tests {
|
||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
|
||||
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
|
||||
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
||||
let exe = std::env::consts::EXE_SUFFIX;
|
||||
@@ -212,7 +218,7 @@ mod tests {
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("irtt-cli{exe}")),
|
||||
detect_applet_from_argv0(&format!("irtt-client{exe}")),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
|
||||
@@ -288,7 +294,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: vec!["irtt-cli".into(), "host:2112".into()],
|
||||
argv: vec!["irtt-client".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -311,7 +317,7 @@ mod tests {
|
||||
fn unknown_irtt_binary_name_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||
assert!(err.contains("irtt-cli"));
|
||||
assert!(err.contains("irtt-client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("irtt-tui"));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_cli::entry::main()
|
||||
irtt_app::entry::client_main()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_cli::entry::main()
|
||||
irtt_app::entry::dispatcher_main()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_cli::entry::main()
|
||||
irtt_app::entry::server_main()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_cli::entry::main()
|
||||
irtt_app::entry::tui_main()
|
||||
}
|
||||
@@ -9,17 +9,24 @@ use clap::{Parser, ValueEnum};
|
||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
|
||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||
pub struct ClientArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 0..,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-cli host-a:2112 host-b:2112\n irtt-cli eu=host-a:2112 us=host-b:2112\n irtt-cli host-a:2112 eu=host-b:2112"
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
/// Read complete desired target sets from standard input in continuous mode.
|
||||
#[arg(
|
||||
long,
|
||||
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
|
||||
)]
|
||||
pub targets_stdin: bool,
|
||||
|
||||
/// Managed group pacing for multi-target mode.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -34,7 +41,7 @@ pub struct ClientArgs {
|
||||
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."
|
||||
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 when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
@@ -86,12 +93,16 @@ impl ClientArgs {
|
||||
/// Targets are only required here. `--list-columns` returns before this is
|
||||
/// called, so listing columns still needs no target.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
}
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -149,7 +160,7 @@ mod tests {
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-cli"];
|
||||
let mut argv = vec!["irtt-client"];
|
||||
argv.extend_from_slice(args);
|
||||
ClientArgs::try_parse_from(argv)
|
||||
}
|
||||
@@ -159,14 +170,14 @@ mod tests {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.targets[0].label, None);
|
||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||
assert_eq!(args.interval, Duration::from_secs(1));
|
||||
assert_eq!(args.format, OutputFormat::Table);
|
||||
assert_eq!(args.columns, None);
|
||||
assert_eq!(args.header, HeaderMode::Auto);
|
||||
assert!(!args.targets_stdin);
|
||||
assert!(!args.is_continuous());
|
||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||
|
||||
@@ -254,8 +265,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
||||
assert!(parse(&["label="]).is_err());
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -296,6 +307,34 @@ mod tests {
|
||||
assert!(help.contains("--loose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_empty_hmac_key_is_accepted() {
|
||||
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert!(setup.targets.is_empty());
|
||||
assert!(setup.stdin_controlled);
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(
|
||||
config.completion,
|
||||
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||
);
|
||||
assert_eq!(config.max_live_target_generations, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_format_summary_policy_is_table_only() {
|
||||
assert!(OutputFormat::Table.prints_summary());
|
||||
File renamed without changes.
@@ -802,6 +802,11 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<
|
||||
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
||||
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
||||
let width = column.table_width();
|
||||
let value = if matches!(column, Column::Target) {
|
||||
truncate_for_table(&value, width)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
if column.align_right() {
|
||||
format!("{value:>width$}")
|
||||
} else {
|
||||
@@ -809,6 +814,25 @@ fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortens `value` to fit within `max_width` display characters for TABLE
|
||||
/// presentation only. Values within the limit are returned unchanged. Longer
|
||||
/// values keep a prefix and end with `"..."` so the result is exactly
|
||||
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
|
||||
/// multibyte UTF-8 value cannot be split mid-code-point.
|
||||
///
|
||||
/// This is a presentation-only concern: callers must keep the untruncated
|
||||
/// value for CSV/TSV/JSONL and any non-display use.
|
||||
fn truncate_for_table(value: &str, max_width: usize) -> String {
|
||||
if value.chars().count() <= max_width {
|
||||
return value.to_owned();
|
||||
}
|
||||
let suffix = "...";
|
||||
let prefix_len = max_width.saturating_sub(suffix.len());
|
||||
let mut truncated: String = value.chars().take(prefix_len).collect();
|
||||
truncated.push_str(suffix);
|
||||
truncated
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DelimitedFormat {
|
||||
Csv,
|
||||
@@ -1304,4 +1328,150 @@ mod tests {
|
||||
|
||||
assert!(line.starts_with("{\"target\":\"ams\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_leaves_short_value_unchanged() {
|
||||
assert_eq!(truncate_for_table("ams", 18), "ams");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_leaves_exact_width_value_unchanged() {
|
||||
let value = "a".repeat(18);
|
||||
assert_eq!(truncate_for_table(&value, 18), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_shortens_long_value_to_width() {
|
||||
let value = "a".repeat(40);
|
||||
let truncated = truncate_for_table(&value, 18);
|
||||
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
assert!(truncated.len() <= 18); // ASCII: byte len matches char len
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_keeps_prefix_and_appends_ellipsis_deterministically() {
|
||||
let value = "abcdefghijklmnopqrstuvwxyz";
|
||||
let truncated = truncate_for_table(value, 18);
|
||||
|
||||
assert_eq!(truncated, "abcdefghijklmno...");
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_does_not_split_multibyte_code_points() {
|
||||
// Each character below is multibyte in UTF-8; a byte-index slice
|
||||
// would panic or corrupt the string on a non-boundary cut.
|
||||
let value = "\u{1F600}".repeat(40); // 😀 repeated, well past width 18
|
||||
let truncated = truncate_for_table(&value, 18);
|
||||
|
||||
// Must not panic (validated by test completing) and must remain
|
||||
// valid UTF-8 with exactly 18 chars (15 prefix chars + "...").
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
assert!(truncated.ends_with("..."));
|
||||
assert_eq!(truncated.chars().filter(|c| *c == '\u{1F600}').count(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_with_long_target_keeps_next_column_aligned() {
|
||||
let config = output_config(OutputFormat::Table, None);
|
||||
let long_target = "a".repeat(40);
|
||||
let header = config.render_header().unwrap();
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Target column is truncated to its table width (18), so the
|
||||
// "event" column starts at the same offset for both a normal and an
|
||||
// overlong target label.
|
||||
let short_config = output_config(OutputFormat::Table, None);
|
||||
let short_line = short_config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
let target_width = Column::Target.table_width();
|
||||
let expected_event_offset = target_width + " ".len();
|
||||
assert_eq!(&header[expected_event_offset..][..5], "event");
|
||||
// The event column ("echo_reply") starts at the same offset
|
||||
// regardless of whether the target label was short or overlong.
|
||||
assert_eq!(
|
||||
&line[expected_event_offset..][.."echo_reply".len()],
|
||||
"echo_reply"
|
||||
);
|
||||
assert_eq!(
|
||||
&short_line[expected_event_offset..][.."echo_reply".len()],
|
||||
"echo_reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Csv, Some("target,seq"));
|
||||
let long_target = "a".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target},7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
|
||||
let long_target = "b".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target}\t7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
|
||||
let long_target = "c".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_table_columns_with_target_truncate_same_as_default() {
|
||||
let config = output_config(OutputFormat::Table, Some("target,seq"));
|
||||
let long_target = "d".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target_width = Column::Target.table_width();
|
||||
let expected_prefix: String = long_target
|
||||
.chars()
|
||||
.take(target_width - 3)
|
||||
.chain("...".chars())
|
||||
.collect();
|
||||
assert!(line.starts_with(&expected_prefix));
|
||||
assert_eq!(&line[..target_width], expected_prefix.as_str());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -12,6 +12,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
use irtt_client::SignedDuration;
|
||||
|
||||
/// Rendering used wherever a scalar is unavailable.
|
||||
@@ -61,6 +62,7 @@ pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
||||
}
|
||||
|
||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||
#[cfg(feature = "client")]
|
||||
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
|
||||
value.map(format_ns_f64).unwrap_or_else(absent)
|
||||
}
|
||||
@@ -71,6 +73,7 @@ pub(crate) fn format_duration(value: Duration) -> String {
|
||||
}
|
||||
|
||||
/// Format a signed duration as a nanosecond scalar, preserving its sign.
|
||||
#[cfg(feature = "client")]
|
||||
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
||||
format_ns_i128(value.as_nanos())
|
||||
}
|
||||
@@ -88,6 +91,7 @@ pub(crate) fn format_percent(value: f64) -> String {
|
||||
/// Format `value` as a percentage of `total`, capped at 100%.
|
||||
///
|
||||
/// A zero total has no percentage to report and renders as [`ABSENT`].
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
||||
if total == 0 {
|
||||
ABSENT.to_owned()
|
||||
@@ -97,11 +101,13 @@ pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
||||
}
|
||||
|
||||
/// Format a scalar count.
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_count(value: u64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||
value.map(format_count).unwrap_or_else(absent)
|
||||
}
|
||||
@@ -152,6 +158,11 @@ mod tests {
|
||||
fn durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn signed_durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(
|
||||
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
||||
"-1.5µs"
|
||||
@@ -161,18 +172,33 @@ mod tests {
|
||||
#[test]
|
||||
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||
assert_eq!(format_optional_count(None), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
||||
assert_eq!(format_percent(f64::NAN), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn absent_optional_ns_f64_renders_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn absent_counts_and_ratios_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_count(None), ABSENT);
|
||||
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentages_and_counts_have_one_spelling() {
|
||||
fn percentages_have_one_spelling() {
|
||||
assert_eq!(format_percent(0.0), "0.00%");
|
||||
assert_eq!(format_percent(12.345), "12.35%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn percent_ratios_and_counts_have_one_spelling() {
|
||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
||||
assert_eq!(format_count(42), "42");
|
||||
File renamed without changes.
@@ -1,8 +1,14 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::{
|
||||
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use irtt_server::{ServerConfig, TimestampAllowance};
|
||||
|
||||
/// The ordinary IRTT port, used to build the zero-argument default binds.
|
||||
const DEFAULT_PORT: u16 = 2112;
|
||||
|
||||
// Every policy option is optional. An omitted option leaves the corresponding
|
||||
// `ServerConfig` default in place rather than restating it here, so library and
|
||||
// CLI defaults cannot drift apart.
|
||||
@@ -10,15 +16,15 @@ use irtt_server::{ServerConfig, TimestampAllowance};
|
||||
#[command(
|
||||
name = "irtt-server",
|
||||
about = "Minimal IRTT-compatible UDP server",
|
||||
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
|
||||
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
|
||||
)]
|
||||
pub struct ServerArgs {
|
||||
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
|
||||
#[arg(
|
||||
long,
|
||||
required = true,
|
||||
value_name = "ADDR",
|
||||
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
|
||||
env = "IRTT_SERVER_BIND",
|
||||
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports.\n\nIRTT_SERVER_BIND sets the same thing for deployments that configure by environment (systemd, Docker, ...), and takes exactly one address, the same as one --bind. Leave it unset on ordinary hosts: the wildcard default above is already what you want there. A --bind on the command line overrides it entirely."
|
||||
)]
|
||||
pub bind: Vec<SocketAddr>,
|
||||
|
||||
@@ -26,7 +32,9 @@ pub struct ServerArgs {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KEY",
|
||||
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
|
||||
value_parser = parse_hmac_key,
|
||||
env = "IRTT_SERVER_HMAC",
|
||||
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments, or in the environment as IRTT_SERVER_HMAC."
|
||||
)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
@@ -34,6 +42,7 @@ pub struct ServerArgs {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
env = "IRTT_SERVER_MAX_SESSIONS",
|
||||
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
|
||||
)]
|
||||
pub max_sessions: Option<usize>,
|
||||
@@ -42,16 +51,18 @@ pub struct ServerArgs {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BYTES",
|
||||
env = "IRTT_SERVER_MAX_PACKET_LENGTH",
|
||||
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
|
||||
)]
|
||||
pub max_packet_length: Option<usize>,
|
||||
|
||||
/// Floor on the negotiated send interval, and the allowance refill cadence.
|
||||
/// Floor on the negotiated send interval.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
|
||||
env = "IRTT_SERVER_MIN_INTERVAL",
|
||||
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
|
||||
)]
|
||||
pub min_interval: Option<Duration>,
|
||||
|
||||
@@ -59,6 +70,7 @@ pub struct ServerArgs {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
env = "IRTT_SERVER_BURST",
|
||||
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
|
||||
)]
|
||||
pub burst: Option<u32>,
|
||||
@@ -68,6 +80,7 @@ pub struct ServerArgs {
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
env = "IRTT_SERVER_IDLE_TIMEOUT",
|
||||
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
|
||||
)]
|
||||
pub idle_timeout: Option<Duration>,
|
||||
@@ -77,6 +90,7 @@ pub struct ServerArgs {
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_positive_duration,
|
||||
env = "IRTT_SERVER_MAX_DURATION",
|
||||
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
|
||||
)]
|
||||
pub max_duration: Option<Duration>,
|
||||
@@ -86,6 +100,7 @@ pub struct ServerArgs {
|
||||
long,
|
||||
value_name = "MODE",
|
||||
value_enum,
|
||||
env = "IRTT_SERVER_TIMESTAMP_ALLOWANCE",
|
||||
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
|
||||
)]
|
||||
pub timestamp_allowance: Option<TimestampAllowanceArg>,
|
||||
@@ -93,6 +108,7 @@ pub struct ServerArgs {
|
||||
/// Refuse to provide requested DSCP marking.
|
||||
#[arg(
|
||||
long,
|
||||
env = "IRTT_SERVER_NO_DSCP",
|
||||
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
|
||||
)]
|
||||
pub no_dscp: bool,
|
||||
@@ -123,6 +139,20 @@ impl From<TimestampAllowanceArg> for TimestampAllowance {
|
||||
}
|
||||
|
||||
impl ServerArgs {
|
||||
/// The addresses this invocation binds: the requested `--bind` list, or
|
||||
/// the wildcard IRTT port on both address families if none was given.
|
||||
#[must_use]
|
||||
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
|
||||
if self.bind.is_empty() {
|
||||
vec![
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||
]
|
||||
} else {
|
||||
self.bind.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the server configuration this invocation asks for.
|
||||
#[must_use]
|
||||
pub fn server_config(&self) -> ServerConfig {
|
||||
@@ -160,6 +190,15 @@ impl ServerArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
|
||||
/// enable authentication with predictable, empty key material.
|
||||
fn parse_hmac_key(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("HMAC key must not be empty".to_owned());
|
||||
}
|
||||
Ok(input.to_owned())
|
||||
}
|
||||
|
||||
/// Parses a duration whose zero is meaningful server policy.
|
||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
@@ -212,11 +251,14 @@ mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||
with_env_lock(|| {
|
||||
let mut argv = vec!["irtt-server"];
|
||||
argv.extend_from_slice(args);
|
||||
ServerArgs::try_parse_from(argv)
|
||||
})
|
||||
}
|
||||
|
||||
fn bound(args: &[&str]) -> ServerArgs {
|
||||
@@ -226,12 +268,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_required_and_parses_both_families() {
|
||||
assert!(
|
||||
parse(&[]).is_err(),
|
||||
"a server with no address serves nothing"
|
||||
);
|
||||
|
||||
fn bind_is_optional_and_parses_both_families() {
|
||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
|
||||
|
||||
@@ -245,6 +282,39 @@ mod tests {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"the empty list is the parsed representation of \"no explicit bind\""
|
||||
);
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
|
||||
],
|
||||
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
|
||||
"one explicit bind means exactly that one listener, not that listener plus defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.resolve_binds(), args.bind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_repeatable_and_keeps_the_requested_order() {
|
||||
let args = parse(&[
|
||||
@@ -404,10 +474,128 @@ mod tests {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whitespace_only_hmac_key_is_accepted_literally() {
|
||||
let config = bound(&["--hmac", " "]).server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||
}
|
||||
|
||||
/// Runs `body` with the given environment variables set, restoring
|
||||
/// whatever was there before (present or absent) once `body` returns.
|
||||
/// Held under `with_env_lock` for the whole span, including `body`, so a
|
||||
/// `parse` call elsewhere never observes a partially-applied environment.
|
||||
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
|
||||
with_env_lock(|| {
|
||||
let previous: Vec<(&str, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| (*k, std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
std::env::set_var(k, v);
|
||||
}
|
||||
let result = body();
|
||||
for (k, v) in previous {
|
||||
match v {
|
||||
Some(v) => std::env::set_var(k, v),
|
||||
None => std::env::remove_var(k),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_variables_supply_policy_when_no_flag_is_given() {
|
||||
with_env(
|
||||
&[
|
||||
("IRTT_SERVER_MAX_SESSIONS", "512"),
|
||||
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
|
||||
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
|
||||
("IRTT_SERVER_BURST", "3"),
|
||||
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
|
||||
("IRTT_SERVER_MAX_DURATION", "2m"),
|
||||
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
|
||||
("IRTT_SERVER_HMAC", "secret"),
|
||||
("IRTT_SERVER_NO_DSCP", "true"),
|
||||
],
|
||||
|| {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert!(!config.dscp_allowed());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_flag_overrides_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(
|
||||
config.max_sessions(),
|
||||
4,
|
||||
"the command-line value wins over the environment"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_wins_over_the_built_in_default() {
|
||||
with_env(&[("IRTT_SERVER_BURST", "9")], || {
|
||||
let config = bound(&[]).server_config();
|
||||
assert_eq!(
|
||||
config.burst_allowance(),
|
||||
9,
|
||||
"an unset flag still falls back to the environment before the library default"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Serializes every test in this module tree that touches process
|
||||
//! environment variables.
|
||||
//!
|
||||
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
|
||||
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
|
||||
//! Every test that parses `ServerArgs` — whether or not it sets an env var
|
||||
//! itself — must go through [`with_env_lock`], or a parse in one test thread
|
||||
//! can observe a value another thread's test set only for the duration of
|
||||
//! its own assertions. Reentrant per-thread, since a test that sets env vars
|
||||
//! typically calls `parse` again from inside its own locked section.
|
||||
|
||||
use std::{cell::Cell, sync::Mutex};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
if ENV_LOCK_HELD.with(Cell::get) {
|
||||
return f();
|
||||
}
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
ENV_LOCK_HELD.with(|held| held.set(true));
|
||||
let result = f();
|
||||
ENV_LOCK_HELD.with(|held| held.set(false));
|
||||
result
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod args;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod env_lock;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
@@ -0,0 +1,209 @@
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{address_family_available, ServerSet, ServerSetError};
|
||||
|
||||
use super::args::ServerArgs;
|
||||
|
||||
/// How often the shutdown future samples the process-wide signal flag.
|
||||
///
|
||||
/// The flag a signal handler sets has no async wake mechanism, so the future
|
||||
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
|
||||
/// after that future becomes ready, including fanning it out to the listeners.
|
||||
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Runs the configured listeners until the process is asked to shut down.
|
||||
///
|
||||
/// The applet owns exactly one current-thread Tokio runtime and one
|
||||
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
|
||||
/// is an ordinary set of one and takes the same path as a set of several: one
|
||||
/// construction, one shutdown future, one supervision rule. Listener
|
||||
/// concurrency is async I/O concurrency and needs no worker threads.
|
||||
pub fn run_server(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve(args, shutdown_requested))
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = args.server_config();
|
||||
let binds = args.resolve_binds();
|
||||
let is_implicit_default = args.bind.is_empty();
|
||||
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||
Ok(set) => set,
|
||||
Err(ServerSetError::ListenerSetup { addr, source }) => {
|
||||
let family_available = address_family_available(addr);
|
||||
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
|
||||
Some(remaining) => {
|
||||
eprintln!(
|
||||
"irtt-server: default listener {addr} unavailable ({source}); \
|
||||
continuing with the remaining default listener"
|
||||
);
|
||||
// `remaining`'s lone survivor is bound exactly as it
|
||||
// would be alone: the address family that just failed
|
||||
// has no local support at all, so nothing on that family
|
||||
// could be competing for the surviving listener's port.
|
||||
ServerSet::bind(remaining, config).await?
|
||||
}
|
||||
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
// Only once every listener is up. Announcing one address and then failing
|
||||
// to bind the next would report a service that never started.
|
||||
for addr in set.local_addrs() {
|
||||
eprintln!("irtt-server: listening on {addr}");
|
||||
}
|
||||
set.run(shutdown_signal(shutdown_requested)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a listener-setup failure for `failed_addr` should be retried with
|
||||
/// a reduced bind set instead of failing the whole invocation, and if so,
|
||||
/// what to retry with.
|
||||
///
|
||||
/// Only the implicit zero-argument default gets this treatment, and only
|
||||
/// when `failed_addr`'s own address family has no local support on this host
|
||||
/// at all (`family_available` is `false`) — not merely because its specific
|
||||
/// port was unavailable for some other reason: already in use, permission
|
||||
/// denied, no safe wildcard reply-source path, or anything else
|
||||
/// `ServerSet::bind` can fail with. Those are real configuration or
|
||||
/// environment problems this invocation should fail on, not silently work
|
||||
/// around by dropping a listener the operator asked for. An explicit
|
||||
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
|
||||
/// regardless.
|
||||
fn implicit_default_fallback(
|
||||
is_implicit_default: bool,
|
||||
family_available: bool,
|
||||
original_binds: &[SocketAddr],
|
||||
failed_addr: SocketAddr,
|
||||
) -> Option<Vec<SocketAddr>> {
|
||||
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
|
||||
return None;
|
||||
}
|
||||
let remaining: Vec<SocketAddr> = original_binds
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&addr| addr != failed_addr)
|
||||
.collect();
|
||||
(!remaining.is_empty()).then_some(remaining)
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
while !shutdown_requested.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||
/// on the same orchestration path as every other.
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
fn default_binds() -> Vec<SocketAddr> {
|
||||
with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server"])
|
||||
.unwrap()
|
||||
.resolve_binds()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
|
||||
let binds = default_binds();
|
||||
let failed = binds[0];
|
||||
|
||||
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
|
||||
|
||||
assert_eq!(remaining, [binds[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supported_familys_failure_never_falls_back() {
|
||||
// The family itself works fine here; whatever made this specific
|
||||
// bind fail (port in use, permission, ...) is a real problem this
|
||||
// invocation should surface, not paper over.
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, true, &binds, binds[0]),
|
||||
None,
|
||||
"family_available: true means this is not a family-support problem"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_binds_never_fall_back() {
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(false, false, &binds, binds[0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outside_the_requested_set_does_not_fall_back() {
|
||||
let binds = default_binds();
|
||||
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &binds, unrelated),
|
||||
None,
|
||||
"the failing address must actually be one this invocation asked for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_only_remaining_default_address_does_not_fall_back() {
|
||||
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &single, single[0]),
|
||||
None,
|
||||
"no address would be left to retry with"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,13 @@ 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/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 1..,
|
||||
required = true,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
@@ -58,6 +58,7 @@ impl TuiArgs {
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -107,8 +108,7 @@ mod tests {
|
||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.targets[0].label, None);
|
||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
@@ -181,8 +181,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
||||
assert!(parse(&["label="]).is_err());
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -270,6 +270,14 @@ mod tests {
|
||||
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
|
||||
// CommonClientArgs is flattened into both the client and TUI parsers,
|
||||
// so proving rejection once here covers both without duplicating it.
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_help_lists_multi_target_options() {
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,133 @@
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::{env, ffi::OsString, process::ExitCode};
|
||||
|
||||
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
use crate::signal::install_signal_handler;
|
||||
|
||||
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
||||
///
|
||||
/// Chooses an applet by subcommand or by recognized argv0 and may reference
|
||||
/// every applet enabled by the active feature set.
|
||||
pub fn dispatcher_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-client` binary.
|
||||
///
|
||||
/// Runs the stream client directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the client.
|
||||
#[cfg(feature = "client")]
|
||||
pub fn client_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-client", || {
|
||||
run_client_applet(env::args_os().collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-tui` binary.
|
||||
///
|
||||
/// Runs the terminal UI directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the TUI.
|
||||
#[cfg(feature = "tui")]
|
||||
pub fn tui_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-server` binary.
|
||||
///
|
||||
/// Runs the server directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the server.
|
||||
#[cfg(feature = "server")]
|
||||
pub fn server_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-server", || {
|
||||
run_server_applet(env::args_os().collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn run_with_exit_code(
|
||||
program: &str,
|
||||
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> ExitCode {
|
||||
match f() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("{program}: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<OsString> = env::args_os().collect();
|
||||
let (requested, argv) = match dispatch_from_argv(argv)? {
|
||||
AppletDispatch::Run { applet, argv } => (applet, argv),
|
||||
AppletDispatch::Help(help) => {
|
||||
print!("{help}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match requested {
|
||||
RequestedApplet::Client => run_client_applet(argv),
|
||||
RequestedApplet::Tui => run_tui_applet(argv),
|
||||
RequestedApplet::Server => run_server_applet(argv),
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the shutdown signal handler shared by every applet and hands the
|
||||
/// resulting flag to `f`.
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
fn with_shutdown_flag(
|
||||
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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}"))?;
|
||||
f(shutdown_requested.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client"))]
|
||||
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("client applet is not available; rebuild with the client feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||
crate::cmd::server::run_server(args, shutdown_requested)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("server applet is not available; rebuild with the server feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||
}
|
||||
File renamed without changes.
+8
-1
@@ -15,7 +15,7 @@ pub struct CommonClientArgs {
|
||||
pub length: u32,
|
||||
|
||||
/// HMAC key.
|
||||
#[arg(long)]
|
||||
#[arg(long, value_parser = parse_hmac_key)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
/// Clock mode to request.
|
||||
@@ -189,6 +189,13 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("HMAC key must not be empty".to_owned());
|
||||
}
|
||||
Ok(input.to_owned())
|
||||
}
|
||||
|
||||
pub fn parse_server_fill(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("server fill must not be empty".to_owned());
|
||||
File renamed without changes.
@@ -9,9 +9,12 @@ pub use args::{
|
||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||
};
|
||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
||||
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
|
||||
pub use prepare::{
|
||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
pub use session::is_shutdown_requested;
|
||||
pub use targets::{
|
||||
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||
TargetSpec,
|
||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||
PreparedTarget, TargetArg, TargetSpec,
|
||||
};
|
||||
+36
-11
@@ -22,6 +22,9 @@ use super::{
|
||||
|
||||
/// Capacity of the lossy managed presentation event channel.
|
||||
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
|
||||
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
|
||||
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||
|
||||
/// Target and pacing arguments common to the client applets.
|
||||
///
|
||||
@@ -31,6 +34,7 @@ pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||
pub struct TargetSelection<'a> {
|
||||
pub targets: &'a [TargetArg],
|
||||
pub pacing: GroupPacingArg,
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
/// Everything a managed run needs, prepared and validated together.
|
||||
@@ -46,6 +50,8 @@ pub struct ManagedRunSetup {
|
||||
pub client: ClientConfig,
|
||||
/// Send coordination across active targets.
|
||||
pub pacing: ManagedPacing,
|
||||
/// Whether this stream may replace its desired set from stdin.
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
impl ManagedRunSetup {
|
||||
@@ -70,7 +76,7 @@ impl ManagedRunSetup {
|
||||
/// Managed driver configuration for this run.
|
||||
pub fn managed_config(&self) -> ManagedClientConfig {
|
||||
let target_count = self.target_count();
|
||||
ManagedClientConfig {
|
||||
let mut config = ManagedClientConfig {
|
||||
client: self.client.clone(),
|
||||
pacing: self.pacing,
|
||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||
@@ -78,7 +84,13 @@ impl ManagedRunSetup {
|
||||
outcome_history_limit: target_count,
|
||||
max_live_target_generations: target_count,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
if self.stdin_controlled {
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
|
||||
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +103,22 @@ pub fn prepare_managed_run(
|
||||
duration: Duration,
|
||||
selection: TargetSelection<'_>,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
let specs = target_specs(selection.targets)?;
|
||||
let specs = if selection.stdin_controlled {
|
||||
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
|
||||
return Err(format!(
|
||||
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
|
||||
));
|
||||
}
|
||||
super::targets::target_specs_with_empty(selection.targets, true)?
|
||||
} else {
|
||||
target_specs(selection.targets)?
|
||||
};
|
||||
let targets = prepare_managed_targets(specs)?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,17 +141,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: None,
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
TargetArg::new(addr)
|
||||
}
|
||||
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: Some(label.to_owned()),
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
TargetArg::new(format!("{label}={addr}"))
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
@@ -137,7 +153,15 @@ mod tests {
|
||||
pacing: GroupPacingArg,
|
||||
duration: Duration,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
|
||||
prepare_managed_run(
|
||||
&common(&[]),
|
||||
duration,
|
||||
TargetSelection {
|
||||
targets,
|
||||
pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,6 +236,7 @@ mod tests {
|
||||
TargetSelection {
|
||||
targets: &targets,
|
||||
pacing: GroupPacingArg::Staggered,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
File renamed without changes.
@@ -0,0 +1,491 @@
|
||||
use std::{collections::HashSet, fmt};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
|
||||
ClientAuthConfig,
|
||||
};
|
||||
|
||||
/// One raw positional target captured by Clap.
|
||||
///
|
||||
/// Parsing happens during run preparation so invalid target syntax never makes
|
||||
/// Clap include the original argument (which may carry an HMAC key) in an
|
||||
/// error diagnostic.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetArg {
|
||||
input: String,
|
||||
}
|
||||
|
||||
impl TargetArg {
|
||||
pub fn new(input: impl Into<String>) -> Self {
|
||||
Self {
|
||||
input: input.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetArg {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("TargetArg(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture one positional target without exposing it through Clap diagnostics.
|
||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
Ok(TargetArg::new(input))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum TargetAuth {
|
||||
Inherit,
|
||||
Override(Vec<u8>),
|
||||
Disable,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Inherit => f.write_str("Inherit"),
|
||||
Self::Override(_) => f.write_str("Override(<redacted>)"),
|
||||
Self::Disable => f.write_str("Disable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
pub addr: String,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TargetSpec")
|
||||
.field("label", &self.label)
|
||||
.field("addr", &self.addr)
|
||||
.field("auth", &self.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PreparedTarget {
|
||||
pub label: String,
|
||||
pub managed: ManagedTargetConfig,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field(
|
||||
"auth",
|
||||
&match &self.managed.auth {
|
||||
None => TargetAuth::Inherit,
|
||||
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
|
||||
TargetAuth::Override(Vec::new())
|
||||
}
|
||||
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum GroupPacingArg {
|
||||
Staggered,
|
||||
Burst,
|
||||
}
|
||||
|
||||
impl From<GroupPacingArg> for ManagedPacing {
|
||||
fn from(value: GroupPacingArg) -> Self {
|
||||
match value {
|
||||
GroupPacingArg::Staggered => Self::Staggered,
|
||||
GroupPacingArg::Burst => Self::Burst,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TargetParseError {
|
||||
EmptyLabel,
|
||||
EmptyAddress,
|
||||
InvalidEscape,
|
||||
EmptyTarget,
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let message = match self {
|
||||
Self::EmptyLabel => "target label must not be empty",
|
||||
Self::EmptyAddress => "target address must not be empty",
|
||||
Self::InvalidEscape => "invalid target escape sequence",
|
||||
Self::EmptyTarget => "target must not be empty",
|
||||
};
|
||||
f.write_str(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
parts.push(&input[start..index]);
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
parts.push(&input[start..]);
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn unescape(input: &str) -> Result<String, TargetParseError> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut escaped = false;
|
||||
for ch in input.chars() {
|
||||
if escaped {
|
||||
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
output.push(ch);
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else {
|
||||
output.push(ch);
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if input[index..].starts_with("@hmac=") {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_target_syntax(
|
||||
input: &str,
|
||||
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
|
||||
if input.is_empty() {
|
||||
return Err(TargetParseError::EmptyTarget);
|
||||
}
|
||||
let (main, auth) = match hmac_modifier(input)? {
|
||||
Some(index) => {
|
||||
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||
let auth = if value.is_empty() {
|
||||
TargetAuth::Disable
|
||||
} else {
|
||||
TargetAuth::Override(value.into_bytes())
|
||||
};
|
||||
(&input[..index], auth)
|
||||
}
|
||||
None => (input, TargetAuth::Inherit),
|
||||
};
|
||||
let (label, address) = match first_unescaped(main, '=')? {
|
||||
None => (None, unescape(main)?),
|
||||
Some(index) => {
|
||||
let label = unescape(&main[..index])?;
|
||||
let address = unescape(&main[index + '='.len_utf8()..])?;
|
||||
(Some(label), address)
|
||||
}
|
||||
};
|
||||
if label.as_deref().is_some_and(str::is_empty) {
|
||||
return Err(TargetParseError::EmptyLabel);
|
||||
}
|
||||
if address.is_empty() {
|
||||
return Err(TargetParseError::EmptyAddress);
|
||||
}
|
||||
Ok((label, address, auth))
|
||||
}
|
||||
|
||||
pub(crate) fn target_specs_with_empty(
|
||||
targets: &[TargetArg],
|
||||
allow_empty: bool,
|
||||
) -> Result<Vec<TargetSpec>, String> {
|
||||
let mut specs = Vec::with_capacity(targets.len());
|
||||
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
|
||||
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
|
||||
let label = match explicit_label {
|
||||
Some(label) => label,
|
||||
None => {
|
||||
let count = unlabeled_counts.entry(addr.clone()).or_default();
|
||||
*count += 1;
|
||||
if *count == 1 {
|
||||
addr.clone()
|
||||
} else {
|
||||
format!("{}#{}", addr, *count)
|
||||
}
|
||||
}
|
||||
};
|
||||
specs.push(TargetSpec { label, addr, auth });
|
||||
}
|
||||
|
||||
if specs.is_empty() && !allow_empty {
|
||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||
}
|
||||
|
||||
let mut labels = HashSet::new();
|
||||
for spec in &specs {
|
||||
if !labels.insert(spec.label.clone()) {
|
||||
return Err("duplicate target label".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs_with_empty(targets, false)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = match spec.auth {
|
||||
TargetAuth::Inherit => None,
|
||||
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
|
||||
hmac_key: Some(hmac_key),
|
||||
}),
|
||||
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
|
||||
};
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
});
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Parse one complete stdin target set after its line terminator was removed.
|
||||
///
|
||||
/// Commas frame stdin elements only; positional target arguments do not use
|
||||
/// this framing and may therefore contain raw commas.
|
||||
pub fn parse_stdin_target_set(
|
||||
record: &str,
|
||||
maximum_targets: usize,
|
||||
) -> Result<Vec<PreparedTarget>, String> {
|
||||
if record == "[]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
|
||||
if elements.len() > maximum_targets {
|
||||
return Err(format!(
|
||||
"target set exceeds the {maximum_targets}-target limit"
|
||||
));
|
||||
}
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_duplicate_endpoints() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||
assert_eq!(
|
||||
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||
Some(b"x,y".as_slice())
|
||||
);
|
||||
assert_eq!(parsed[1].label, "b");
|
||||
assert_eq!(
|
||||
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
" []"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
"[] "
|
||||
);
|
||||
assert!(parse_stdin_target_set("", 128).is_err());
|
||||
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,487 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io::{self, Write},
|
||||
sync::atomic::AtomicBool,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_client::{
|
||||
managed::{
|
||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
||||
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
|
||||
use super::{
|
||||
args::ClientArgs,
|
||||
output::{EventRenderStats, OutputConfig},
|
||||
};
|
||||
|
||||
use crate::shared::client::expected_probe_count;
|
||||
use crate::shared::client::{
|
||||
is_shutdown_requested,
|
||||
session::{
|
||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
||||
},
|
||||
};
|
||||
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
|
||||
const GIB: u64 = 1024 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub fn run_stream(
|
||||
args: ClientArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if args.list_columns {
|
||||
print!("{}", OutputConfig::list_columns());
|
||||
return Ok(());
|
||||
}
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let multi_target = setup.is_multi_target();
|
||||
let output_config = OutputConfig::new(
|
||||
args.format,
|
||||
args.columns.as_deref(),
|
||||
args.header,
|
||||
args.verbose,
|
||||
)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let continuous = args.is_continuous();
|
||||
let target_count = setup.target_count();
|
||||
|
||||
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
||||
setup.managed_config(),
|
||||
setup.managed_targets(),
|
||||
)?;
|
||||
let handle = owner.handle();
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
show_running_only_summary_note: false,
|
||||
out: &mut stdout,
|
||||
};
|
||||
|
||||
let mut stats = setup
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
(
|
||||
target.label.clone(),
|
||||
StatsCollector::new(stats_config(continuous)),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut terminal_targets = HashSet::new();
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut interrupted = false;
|
||||
let mut stop_requested = false;
|
||||
let mut subscription_closed = false;
|
||||
loop {
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
interrupted = true;
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
}
|
||||
if request_managed_stop_for_peer_close(
|
||||
continuous,
|
||||
interrupted,
|
||||
handle.status().peer_closed_target_outcomes,
|
||||
&mut stop_requested,
|
||||
) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
let drain_state = drain_events(
|
||||
&mut events,
|
||||
&mut stream_output,
|
||||
&mut stats,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
)?;
|
||||
match drain_state {
|
||||
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
||||
ManagedDrainState::Closed => subscription_closed = true,
|
||||
ManagedDrainState::BudgetExhausted => {}
|
||||
}
|
||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if interrupted {
|
||||
eprintln!("interrupted, closing managed run...");
|
||||
}
|
||||
let outcome = owner.join()?;
|
||||
drain_final_events(
|
||||
&mut events,
|
||||
&mut stream_output,
|
||||
&mut stats,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
)?;
|
||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
eprintln!(
|
||||
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
);
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
report_target_failure(target);
|
||||
}
|
||||
}
|
||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||
let terminal_error = match &outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Some(format!("managed driver failed: {failure}"))
|
||||
}
|
||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
||||
.or_else(|| {
|
||||
(!interrupted
|
||||
&& outcome.successful_target_outcomes == 0
|
||||
&& outcome.failed_target_outcomes > 0)
|
||||
.then(|| {
|
||||
format!(
|
||||
"no managed target completed successfully ({} failed)",
|
||||
outcome.failed_target_outcomes
|
||||
)
|
||||
})
|
||||
}),
|
||||
};
|
||||
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||
stream_output.show_running_only_summary_note =
|
||||
continuous && interrupted && stream_output.print_final_summary;
|
||||
for (label, stats) in &stats {
|
||||
if multi_target
|
||||
&& stream_output.print_final_summary
|
||||
&& stream_output.config.prints_summary()
|
||||
{
|
||||
writeln!(stream_output.out)?;
|
||||
writeln!(stream_output.out, "target: {label}")?;
|
||||
}
|
||||
stream_output.print_summary(stats)?;
|
||||
}
|
||||
stream_output.out.flush()?;
|
||||
if let Some(error) = terminal_error {
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_event<W: Write>(
|
||||
event: ManagedEvent,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<String, StatsCollector>,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.id.as_str().to_owned())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
||||
print_events_with_stats(
|
||||
stream_output,
|
||||
std::slice::from_ref(&event),
|
||||
Some(target.id.as_str()),
|
||||
collector,
|
||||
)?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
report_target_failure(&outcome);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_events<W: Write>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<String, StatsCollector>,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
dropped_events: &mut u64,
|
||||
) -> io::Result<ManagedDrainState> {
|
||||
drain_managed_events(events, dropped_events, |event| {
|
||||
process_event(event, stream_output, stats, terminal_targets)
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_final_events<W: Write>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<String, StatsCollector>,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
dropped_events: &mut u64,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
match events.try_recv() {
|
||||
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
|
||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||
*dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_target_failure(target: &ManagedTargetOutcome) {
|
||||
for message in target_failure_messages(target) {
|
||||
eprintln!("{message}");
|
||||
}
|
||||
}
|
||||
|
||||
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
|
||||
messages.push(format!(
|
||||
"irtt-rs: target {} failed ({} {}): {}",
|
||||
target.target.id, failure.phase, failure.kind, failure.message
|
||||
));
|
||||
}
|
||||
if let Some(failure) = &target.cleanup_failure {
|
||||
messages.push(format!(
|
||||
"irtt-rs: target {} cleanup failed ({} {}): {}",
|
||||
target.target.id, failure.phase, failure.kind, failure.message
|
||||
));
|
||||
}
|
||||
messages
|
||||
}
|
||||
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
||||
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" }))
|
||||
}
|
||||
|
||||
struct StreamOutput<'a, W: Write> {
|
||||
config: OutputConfig,
|
||||
header_printed: bool,
|
||||
print_final_summary: bool,
|
||||
show_running_only_summary_note: bool,
|
||||
out: &'a mut W,
|
||||
}
|
||||
impl<W: Write> StreamOutput<'_, W> {
|
||||
fn print_events(
|
||||
&mut self,
|
||||
events: &[ClientEvent],
|
||||
target: Option<&str>,
|
||||
stats_updates: &[EventRenderStats],
|
||||
) -> io::Result<()> {
|
||||
self.print_header()?;
|
||||
for (event, stats_update) in events.iter().zip(stats_updates) {
|
||||
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
|
||||
writeln!(self.out, "{line}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn print_header(&mut self) -> io::Result<()> {
|
||||
if self.header_printed {
|
||||
return Ok(());
|
||||
}
|
||||
self.header_printed = true;
|
||||
if let Some(header) = self.config.render_header() {
|
||||
writeln!(self.out, "{header}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
||||
if self.print_final_summary && self.config.prints_summary() {
|
||||
write!(
|
||||
self.out,
|
||||
"{}",
|
||||
crate::cmd::client::summary::format_summary_with_options(
|
||||
&stats.snapshot(),
|
||||
crate::cmd::client::summary::SummaryFormatOptions {
|
||||
verbose: self.config.summary_verbose(),
|
||||
show_running_only_note: self.show_running_only_summary_note
|
||||
}
|
||||
)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_events_with_stats<W: Write>(
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
events: &[ClientEvent],
|
||||
target: Option<&str>,
|
||||
stats: &mut StatsCollector,
|
||||
) -> io::Result<()> {
|
||||
let updates = events
|
||||
.iter()
|
||||
.map(|event| EventRenderStats::from(stats.process(event)))
|
||||
.collect::<Vec<_>>();
|
||||
stream_output.print_events(events, target, &updates)
|
||||
}
|
||||
|
||||
fn stats_config(continuous: bool) -> StatsConfig {
|
||||
if continuous {
|
||||
StatsConfig::continuous()
|
||||
} else {
|
||||
StatsConfig::finite()
|
||||
}
|
||||
}
|
||||
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
|
||||
if args.is_continuous() || args.duration.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
||||
let total_probe_count =
|
||||
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
|
||||
// Ask the stats crate what the configuration this run will actually use
|
||||
// retains; the CLI owns the probe count and the thresholds, not the
|
||||
// retention model.
|
||||
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
|
||||
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
||||
return None;
|
||||
}
|
||||
let formatted = if estimated_bytes >= GIB {
|
||||
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
|
||||
} else {
|
||||
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
|
||||
};
|
||||
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(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::managed::{
|
||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn long_finite_args() -> ClientArgs {
|
||||
use clap::Parser;
|
||||
|
||||
ClientArgs::try_parse_from([
|
||||
"irtt-cli",
|
||||
"--duration",
|
||||
"4200s",
|
||||
"--interval",
|
||||
"100ms",
|
||||
"127.0.0.1:2112",
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Smallest target count whose aggregate estimate reaches the warning
|
||||
/// threshold, derived from the stats crate's own estimate rather than from
|
||||
/// any bytes-per-probe assumption held here.
|
||||
fn first_warning_target_count(args: &ClientArgs) -> usize {
|
||||
let probes = expected_probe_count(args.duration, args.interval);
|
||||
(1..=1024)
|
||||
.find(|targets| {
|
||||
let total = probes.saturating_mul(*targets as u64);
|
||||
stats_config(false).estimated_retained_bytes(total)
|
||||
>= FINITE_STATS_MEMORY_WARNING_BYTES
|
||||
})
|
||||
.expect("a long finite run should cross the warning threshold within 1024 targets")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_accounts_for_target_count() {
|
||||
let args = long_finite_args();
|
||||
let crossing = first_warning_target_count(&args);
|
||||
|
||||
assert!(
|
||||
crossing > 1,
|
||||
"this run should need more than one target to cross the threshold, \
|
||||
otherwise the test proves nothing about target-count accounting"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing - 1).is_none(),
|
||||
"an aggregate estimate below the threshold should not warn"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing).is_some(),
|
||||
"an aggregate estimate at the threshold should warn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
||||
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lagged_events_are_counted_saturatingly() {
|
||||
let mut dropped = 0_u64;
|
||||
dropped = dropped.saturating_add(7);
|
||||
dropped = dropped.saturating_add(3);
|
||||
assert_eq!(dropped, 10);
|
||||
assert_eq!(u64::MAX.saturating_add(1), u64::MAX);
|
||||
assert!(dropped_event_warning(dropped)
|
||||
.unwrap()
|
||||
.contains("dropped 10 managed run events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
|
||||
let target = ManagedTargetOutcome {
|
||||
target: TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 1,
|
||||
},
|
||||
server_addr: Arc::from("127.0.0.1:2112"),
|
||||
remote: None,
|
||||
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Receiving,
|
||||
kind: ManagedTargetFailureKind::Protocol,
|
||||
message: "primary failure".into(),
|
||||
}),
|
||||
packets_sent: 0,
|
||||
replies_received: 0,
|
||||
duplicates: 0,
|
||||
late: 0,
|
||||
warning_events: 0,
|
||||
cleanup_failure: Some(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Closing,
|
||||
kind: ManagedTargetFailureKind::Socket,
|
||||
message: "cleanup failure".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
target_failure_messages(&target),
|
||||
[
|
||||
"irtt-rs: target edge failed (receiving protocol): primary failure",
|
||||
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
use std::{
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::ServerSet;
|
||||
|
||||
use super::args::ServerArgs;
|
||||
|
||||
/// How often the shutdown future samples the process-wide signal flag.
|
||||
///
|
||||
/// The flag a signal handler sets has no async wake mechanism, so the future
|
||||
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
|
||||
/// after that future becomes ready, including fanning it out to the listeners.
|
||||
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Runs the configured listeners until the process is asked to shut down.
|
||||
///
|
||||
/// The applet owns exactly one current-thread Tokio runtime and one
|
||||
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
|
||||
/// is an ordinary set of one and takes the same path as a set of several: one
|
||||
/// construction, one shutdown future, one supervision rule. Listener
|
||||
/// concurrency is async I/O concurrency and needs no worker threads.
|
||||
pub fn run_server(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve(args, shutdown_requested))
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = args.server_config();
|
||||
let set = ServerSet::bind(args.bind, config).await?;
|
||||
// Only once every listener is up. Announcing one address and then failing
|
||||
// to bind the next would report a service that never started.
|
||||
for addr in set.local_addrs() {
|
||||
eprintln!("irtt-server: listening on {addr}");
|
||||
}
|
||||
set.run(shutdown_signal(shutdown_requested)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
while !shutdown_requested.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||
/// on the same orchestration path as every other.
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
||||
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||
let args = ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap();
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsString,
|
||||
process::ExitCode,
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
|
||||
signal::install_signal_handler,
|
||||
};
|
||||
|
||||
pub fn main() -> ExitCode {
|
||||
match run_from_env() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("irtt-rs: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<OsString> = env::args_os().collect();
|
||||
let (requested, argv) = match dispatch_from_argv(argv)? {
|
||||
AppletDispatch::Run { applet, argv } => (applet, argv),
|
||||
AppletDispatch::Help(help) => {
|
||||
print!("{help}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||
install_signal_handler(Arc::clone(&shutdown_requested))
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
|
||||
match requested {
|
||||
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
||||
RequestedApplet::Server => run_server_applet(argv, shutdown_requested.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
fn run_client_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client"))]
|
||||
fn run_client_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("client applet is not available; rebuild with the client feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn run_server_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||
crate::cmd::server::run_server(args, shutdown_requested)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
fn run_server_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("server applet is not available; rebuild with the server feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(
|
||||
argv: Vec<OsString>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
fn run_tui_applet(
|
||||
_argv: Vec<OsString>,
|
||||
_shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetArg {
|
||||
pub label: Option<String>,
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedTarget {
|
||||
pub label: String,
|
||||
pub managed: ManagedTargetConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum GroupPacingArg {
|
||||
Staggered,
|
||||
Burst,
|
||||
}
|
||||
|
||||
impl From<GroupPacingArg> for ManagedPacing {
|
||||
fn from(value: GroupPacingArg) -> Self {
|
||||
match value {
|
||||
GroupPacingArg::Staggered => Self::Staggered,
|
||||
GroupPacingArg::Burst => Self::Burst,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
|
||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
match input.split_once('=') {
|
||||
None => Ok(TargetArg {
|
||||
label: None,
|
||||
addr: input.to_owned(),
|
||||
}),
|
||||
Some((label, addr)) => {
|
||||
if label.is_empty() {
|
||||
return Err("target label must not be empty".to_owned());
|
||||
}
|
||||
if addr.is_empty() {
|
||||
return Err("target address must not be empty".to_owned());
|
||||
}
|
||||
Ok(TargetArg {
|
||||
label: Some(label.to_owned()),
|
||||
addr: addr.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
let mut specs = Vec::with_capacity(targets.len());
|
||||
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
|
||||
for target in targets {
|
||||
let label = match &target.label {
|
||||
Some(label) => label.clone(),
|
||||
None => {
|
||||
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
|
||||
*count += 1;
|
||||
if *count == 1 {
|
||||
target.addr.clone()
|
||||
} else {
|
||||
format!("{}#{}", target.addr, *count)
|
||||
}
|
||||
}
|
||||
};
|
||||
specs.push(TargetSpec {
|
||||
label,
|
||||
addr: target.addr.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if specs.is_empty() {
|
||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||
}
|
||||
|
||||
let mut labels = HashSet::new();
|
||||
for spec in &specs {
|
||||
if !labels.insert(spec.label.clone()) {
|
||||
return Err(format!("duplicate target label {:?}", spec.label));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label.clone(),
|
||||
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
|
||||
});
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: None,
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: Some(label.to_owned()),
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_without_equals_is_unlabeled() {
|
||||
let target = parse_target("host.example").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "host.example");
|
||||
|
||||
let target = parse_target("host.example:2112").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "host.example:2112");
|
||||
|
||||
let target = parse_target("[::1]:2112").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "[::1]:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_with_equals_splits_on_first() {
|
||||
let target = parse_target("eu=host.example").unwrap();
|
||||
assert_eq!(target.label, Some("eu".to_owned()));
|
||||
assert_eq!(target.addr, "host.example");
|
||||
|
||||
let target = parse_target("eu=host.example:2112").unwrap();
|
||||
assert_eq!(target.label, Some("eu".to_owned()));
|
||||
assert_eq!(target.addr, "host.example:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_rejects_empty_label_or_address() {
|
||||
assert!(parse_target("=host.example").is_err());
|
||||
assert!(parse_target("eu=").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
|
||||
let targets = vec![
|
||||
unlabeled("host-a:2112"),
|
||||
labeled("host-a:2112", "host-b:2112"),
|
||||
];
|
||||
let err = target_specs(&targets).unwrap_err();
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_with_mixed_labels() {
|
||||
let targets = vec![
|
||||
unlabeled("local"),
|
||||
labeled("eu", "eu.example"),
|
||||
unlabeled("backup"),
|
||||
labeled("us", "us.example"),
|
||||
];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "backup", "us"]);
|
||||
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
|
||||
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
||||
assert_eq!(labels, ["foo", "eu", "foo#2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
let targets = vec![
|
||||
unlabeled("host.example"),
|
||||
unlabeled("host.example"),
|
||||
labeled("host.example#2", "other.example"),
|
||||
];
|
||||
let err = target_specs(&targets).unwrap_err();
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
|
||||
let specs = vec![
|
||||
TargetSpec {
|
||||
label: "a".to_owned(),
|
||||
addr: "example.test".to_owned(),
|
||||
},
|
||||
TargetSpec {
|
||||
label: "b".to_owned(),
|
||||
addr: "example.test".to_owned(),
|
||||
},
|
||||
];
|
||||
let targets = prepare_managed_targets(specs).unwrap();
|
||||
assert_eq!(targets[0].managed.server_addr, "example.test");
|
||||
assert_eq!(targets[1].managed.server_addr, "example.test");
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn irtt_rs() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn irtt_rs_with_arg0(arg0: &str) -> Command {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut command = irtt_rs();
|
||||
command.arg0(arg0);
|
||||
command
|
||||
}
|
||||
|
||||
fn output_text(output: &Output) -> String {
|
||||
let mut text = String::new();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_help_shows_dispatcher_help() {
|
||||
let output = irtt_rs().arg("--help").output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
|
||||
assert!(text.contains("client"));
|
||||
assert!(text.contains("tui"));
|
||||
assert!(text.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_without_applet_errors() {
|
||||
let output = irtt_rs().output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(text.contains("choose an applet"), "{text}");
|
||||
assert!(text.contains("client"));
|
||||
assert!(text.contains("tui"));
|
||||
assert!(text.contains("server"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn canonical_client_subcommand_dispatches_to_client() {
|
||||
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||
assert!(text.contains("--format <FORMAT>"));
|
||||
assert!(text.contains("--columns <COLUMNS>"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client"))]
|
||||
#[test]
|
||||
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
text.contains("client applet is not available; rebuild with the client feature"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn canonical_server_subcommand_dispatches_to_server() {
|
||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
assert!(text.contains("--max-sessions <COUNT>"));
|
||||
assert!(text.contains("--idle-timeout <DURATION>"));
|
||||
assert!(text.contains("--timestamp-allowance <MODE>"));
|
||||
assert!(text.contains("--no-dscp"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
#[test]
|
||||
fn canonical_server_subcommand_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
text.contains("server applet is not available; rebuild with the server feature"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn standalone_server_binary_exposes_the_same_arguments() {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn client_applet_name_dispatches_to_client() {
|
||||
let output = irtt_rs_with_arg0("irtt-cli")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||
assert!(text.contains("--format <FORMAT>"));
|
||||
assert!(text.contains("--columns <COLUMNS>"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(feature = "client")))]
|
||||
#[test]
|
||||
fn client_applet_name_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-cli")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
text.contains("client applet is not available; rebuild with the client feature"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "tui"))]
|
||||
#[test]
|
||||
fn tui_applet_name_dispatches_to_tui_when_enabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-tui")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible TUI client"));
|
||||
assert!(text.contains("--duration <DURATION>"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(feature = "tui")))]
|
||||
#[test]
|
||||
fn tui_applet_name_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-tui")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(text.contains("TUI applet is not available"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "server"))]
|
||||
#[test]
|
||||
fn server_applet_name_dispatches_to_server_when_enabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-server")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
||||
assert!(text.contains("--bind <ADDR>"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(feature = "server")))]
|
||||
#[test]
|
||||
fn server_applet_name_reports_unavailable_when_disabled() {
|
||||
let output = irtt_rs_with_arg0("irtt-server")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(text.contains("server applet is not available"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unknown_irtt_applet_name_errors() {
|
||||
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
|
||||
let text = output_text(&output);
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
|
||||
assert!(text.contains("irtt-cli"));
|
||||
assert!(text.contains("irtt-tui"));
|
||||
assert!(text.contains("irtt-server"));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,74 +0,0 @@
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct InTreeServer {
|
||||
pub addr: SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
|
||||
}
|
||||
|
||||
impl InTreeServer {
|
||||
pub fn start() -> Self {
|
||||
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let done = thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build in-tree test server runtime");
|
||||
runtime.block_on(async move {
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||
let mut server = Server::bind(bind_addr, ServerConfig::default()).await?;
|
||||
startup_tx
|
||||
.send(server.local_addr()?)
|
||||
.expect("test stopped waiting for in-tree server startup");
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
let _ = shutdown_tx.send(());
|
||||
let result = done.join().expect("in-tree test server thread panicked");
|
||||
panic!("in-tree test server failed before startup ({error}): {result:?}");
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
done: Some(done),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(done) = self.done.take() {
|
||||
done.join()
|
||||
.expect("in-tree test server thread panicked")
|
||||
.expect("in-tree test server stopped with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InTreeServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
@@ -27,7 +27,25 @@ tokio = ["dep:tokio"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
|
||||
[[example]]
|
||||
name = "blocking_client"
|
||||
doc-scrape-examples = true
|
||||
|
||||
[[example]]
|
||||
name = "tokio_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[[example]]
|
||||
name = "managed_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
# Keep the packaged client testable against the latest published server while
|
||||
# workspace tests continue to exercise the local source through this path.
|
||||
irtt-server = { version = "0.5.1", path = "../irtt-server" }
|
||||
proptest = "1.11.0"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
@@ -0,0 +1,37 @@
|
||||
# irtt-client
|
||||
|
||||
Reusable client/session library for IRTT-compatible round-trip-time probing:
|
||||
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
|
||||
classification, and a typed event stream.
|
||||
|
||||
## Looking for the CLI?
|
||||
|
||||
If you just want to run probes from the command line, use the
|
||||
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
|
||||
which installs the `irtt-client` binary. This crate is the library the
|
||||
binaries are built on.
|
||||
|
||||
## API tiers
|
||||
|
||||
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
|
||||
default build.
|
||||
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
|
||||
own a runtime and drive readiness directly.
|
||||
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
|
||||
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
|
||||
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
|
||||
own dedicated current-thread runtime.
|
||||
|
||||
Tokio stays optional: the default build has no runtime and no Tokio
|
||||
dependency at all.
|
||||
|
||||
See `examples/` in the repository for runnable examples of each tier.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Runtime-free blocking client example.
|
||||
//!
|
||||
//! Opens a session against a local IRTT-compatible server, sends a few
|
||||
//! probes, and prints the events observed. Uses only [`Client`], which has no
|
||||
//! Tokio dependency and works in a default (`--no-default-features`) build.
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example blocking_client
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open_timeouts` for a
|
||||
//! fast demonstration; a real caller would normally keep the default retry
|
||||
//! schedule.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent};
|
||||
|
||||
const RUN_DURATION: Duration = Duration::from_secs(2);
|
||||
|
||||
fn main() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
duration: Some(RUN_DURATION),
|
||||
interval: Duration::from_millis(200),
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(100)),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match Client::connect(config) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.open() {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The run itself lasts `duration`, but a lost probe isn't classified as
|
||||
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
|
||||
// that much past the run's own end so a loss near the end can still be
|
||||
// observed and printed before this example gives up and closes.
|
||||
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
|
||||
while Instant::now() < deadline && !client.is_run_complete() {
|
||||
// send_probe is caller-paced: only call it once its own schedule
|
||||
// says a probe is due, rather than as fast as the loop spins.
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry silently until the
|
||||
// safety deadline instead of reporting it.
|
||||
match client.send_probe() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match client.recv_once() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("receive failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
match client.poll_timeouts() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("timeout polling failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match client.close() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||
}
|
||||
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Managed multi-target client example (`tokio` feature).
|
||||
//!
|
||||
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
|
||||
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
|
||||
//! that does not want to touch Tokio directly can still get managed
|
||||
//! multi-target orchestration, retries, and a lossy event stream.
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example managed_client --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! This example does not wait for the managed run to finish naturally: it
|
||||
//! requests a stop after a fixed short window so it exits promptly even
|
||||
//! without a reachable server.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
|
||||
ManagedTargetConfig,
|
||||
};
|
||||
use irtt_client::ClientConfig;
|
||||
|
||||
fn main() {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(200),
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..Default::default()
|
||||
};
|
||||
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
|
||||
|
||||
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(err) => {
|
||||
eprintln!("failed to start managed client: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stop_after = Duration::from_secs(1);
|
||||
let deadline = std::time::Instant::now() + stop_after;
|
||||
while std::time::Instant::now() < deadline {
|
||||
match events.try_recv() {
|
||||
Ok(event) => print_event(&event),
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
}
|
||||
|
||||
// `stop()` requests the stop synchronously; the returned receipt is only
|
||||
// useful for awaiting durable acknowledgement, which this blocking
|
||||
// example does not need.
|
||||
std::mem::drop(owner.handle().stop());
|
||||
match owner.join() {
|
||||
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
|
||||
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ManagedEvent) {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Low-level Tokio client example (`tokio` feature).
|
||||
//!
|
||||
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
|
||||
//! adapter to reach for when you already have a Tokio runtime and want to
|
||||
//! await sends/receives yourself, rather than use the higher-level `managed`
|
||||
//! driver (see the `managed_client` example).
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example tokio_client --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open_timeouts` for a
|
||||
//! fast demonstration.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
|
||||
|
||||
/// Upper bound on how long the receive wait can go without also calling
|
||||
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
|
||||
/// probe's own timeout deadline, so this example polls on a short fixed
|
||||
/// cadence instead of waiting for the full `probe_timeout()` from whenever
|
||||
/// this loop last ran — that would let poll_timeouts() classify a lost
|
||||
/// probe (and its dependent statistics/session state) far later than it
|
||||
/// actually became lost.
|
||||
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
fn main() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("failed to build Tokio runtime");
|
||||
|
||||
runtime.block_on(run());
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
duration: Some(Duration::from_secs(2)),
|
||||
interval: Duration::from_millis(200),
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match AsyncClient::connect(config).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.open().await {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while !client.is_run_complete() {
|
||||
// Bound the receive wait by the next send/timeout deadline, rather
|
||||
// than awaiting recv() unconditionally: a lost or rate-limited reply
|
||||
// must not stall pacing or timeout classification indefinitely.
|
||||
let wake_at = client
|
||||
.next_send_deadline()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
|
||||
.min()
|
||||
.expect("the fixed poll-interval deadline is always present");
|
||||
|
||||
let recv_result = tokio::select! {
|
||||
events = client.recv() => Some(events),
|
||||
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
|
||||
};
|
||||
if let Some(result) = recv_result {
|
||||
match result {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("receive failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry forever instead of
|
||||
// reporting it.
|
||||
match client.send_probe().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match client.poll_timeouts() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("timeout polling failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match client.close().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||
}
|
||||
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,22 @@ 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.
|
||||
/// wall time when both are available. The client send endpoint is the
|
||||
/// correlated Linux kernel TX timestamp for this send when one is
|
||||
/// available and plausible for this send, and the userspace post-send wall
|
||||
/// time otherwise. The server receive endpoint is whatever wall time the
|
||||
/// peer reported; against this crate's own Tokio server runtime on Linux
|
||||
/// (the `irtt-server` applet), that is its kernel receive timestamp when
|
||||
/// one is available and plausible, and its userspace receive wall time
|
||||
/// otherwise. Against another IRTT-compatible peer, or an application
|
||||
/// driving `irtt_server::ServerCore::handle_datagram` directly, the
|
||||
/// client has no way to know which clock source produced it — the public
|
||||
/// `ServerCore` API never supplies a kernel timestamp of its own accord.
|
||||
/// `RttSample::raw` is unaffected and remains a userspace
|
||||
/// monotonic measurement, but `adjusted` and `effective` can still shift:
|
||||
/// when the session negotiated wall-clock timing rather than monotonic,
|
||||
/// server processing time is derived from this same server receive
|
||||
/// endpoint.
|
||||
pub client_to_server: Option<SignedDuration>,
|
||||
/// Server-to-client delay.
|
||||
///
|
||||
|
||||
@@ -13,10 +13,19 @@
|
||||
//! also signed when the required wall-clock timestamps are available; negative
|
||||
//! values usually indicate client/server clock skew.
|
||||
//!
|
||||
//! [`Client`] is the runtime-free low-level blocking adapter. With the
|
||||
//! `tokio` feature, `AsyncClient` provides the corresponding Tokio adapter;
|
||||
//! [`managed`] contains the unified managed task/handle implementation,
|
||||
//! including the dedicated-runtime blocking frontend for synchronous callers.
|
||||
//! [`Client`] is the runtime-free low-level blocking adapter and requires no
|
||||
//! Tokio dependency at all; it is what a caller without a runtime, or that
|
||||
//! wants full control over its own event loop, should start from. With the
|
||||
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
||||
//! adapter for a caller that owns a runtime and drives socket readiness
|
||||
//! itself. [`managed`] sits above both: `ManagedClientTask` /
|
||||
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
||||
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
|
||||
//! feature) wraps that in a synchronous owner backed by its own dedicated
|
||||
//! current-thread runtime for callers that want managed multi-target
|
||||
//! behavior without touching Tokio themselves.
|
||||
//!
|
||||
//! See `examples/` in the repository for a runnable example of each tier.
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
||||
@@ -250,15 +250,11 @@ fn dynamic_update_reaches_background_worker() {
|
||||
.unwrap();
|
||||
let handle = owner.handle();
|
||||
|
||||
let acknowledgement = test_runtime().block_on(async {
|
||||
timeout(
|
||||
Duration::from_secs(2),
|
||||
handle.update_targets(vec![target(&server)]).unwrap(),
|
||||
)
|
||||
.await
|
||||
let acknowledgement = handle
|
||||
.update_targets(vec![target(&server)])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
});
|
||||
.blocking_wait()
|
||||
.unwrap();
|
||||
assert_eq!(acknowledgement.sequence, 1);
|
||||
server.wait_probe();
|
||||
test_runtime().block_on(async {
|
||||
|
||||
@@ -236,7 +236,21 @@ impl ManagedClientHandle {
|
||||
ManagedStopReceipt::new(Arc::clone(&self.stop))
|
||||
}
|
||||
|
||||
/// Submit one complete desired target set without awaiting queue capacity.
|
||||
/// Submit the complete desired target set, replacing any previous one.
|
||||
///
|
||||
/// `targets` is not a delta: any live target whose id is missing from
|
||||
/// `targets` is stopped and removed. A target that is still present with an
|
||||
/// unchanged configuration continues undisturbed — it is not restarted and
|
||||
/// does not get a new generation. A target present with a *changed*
|
||||
/// configuration, or whose current generation already reached a terminal
|
||||
/// state (e.g. failed, completed, or was closed by the peer), is treated as
|
||||
/// a fresh start: it is retired and a new generation is created for it, even
|
||||
/// if the configuration is byte-for-byte identical to the one that just
|
||||
/// finished.
|
||||
///
|
||||
/// This call enqueues the update without waiting for queue capacity; await
|
||||
/// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
|
||||
/// was applied.
|
||||
pub fn update_targets(
|
||||
&self,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
@@ -287,6 +301,19 @@ pub struct ManagedCommandReceipt {
|
||||
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
}
|
||||
|
||||
impl ManagedCommandReceipt {
|
||||
/// Block until the target-set transaction is applied or rejected.
|
||||
///
|
||||
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
|
||||
/// must not be called from an asynchronous runtime; use the [`Future`]
|
||||
/// implementation there instead.
|
||||
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
self.receiver
|
||||
.blocking_recv()
|
||||
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedCommandReceipt {
|
||||
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
|
||||
@@ -1576,6 +1576,37 @@ fn authenticated_peer_close_outcome() {
|
||||
assert!(!has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_leaves_other_explicitly_managed_targets_running() {
|
||||
let closing = start_server(ServerBehavior::PeerClose, None);
|
||||
let continuing = start_server(ServerBehavior::Echo, None);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.client.duration = None;
|
||||
let (task, handle) = ManagedClient::task(
|
||||
managed,
|
||||
vec![
|
||||
target("closing", closing.addr),
|
||||
target("continuing", continuing.addr),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = runtime();
|
||||
let mut task = Box::pin(task);
|
||||
|
||||
drive_task_until(&runtime, &mut task, |task| {
|
||||
handle.status().peer_closed_target_outcomes == 1
|
||||
&& task.targets[1].counters.packets_sent != 0
|
||||
});
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||
|
||||
let stop = handle.stop();
|
||||
runtime.block_on(task);
|
||||
runtime.block_on(stop);
|
||||
closing.finish();
|
||||
assert!(!probes(&continuing.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_during_stop_drain_remains_authoritative() {
|
||||
let key = b"managed-drain-peer-close".to_vec();
|
||||
|
||||
@@ -3097,4 +3097,690 @@ mod tests {
|
||||
Some(SignedDuration::from_nanos(12_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
/// Model-based tests for `SessionMachine`'s open-session operation
|
||||
/// contract: probe sends, reply classification, timeouts, and close.
|
||||
///
|
||||
/// ## Reference-model boundary
|
||||
///
|
||||
/// The open handshake itself (accept/reject, run-mode, version and
|
||||
/// token checks) is deliberately out of scope here: it already has
|
||||
/// focused deterministic coverage above in `mod tests`, and every
|
||||
/// generated case here starts from an already-`Open` session built the
|
||||
/// same way `open_machine` above builds one, by constructing
|
||||
/// `MachineState::Open` directly. What *is* generated and checked
|
||||
/// against an independent reference model is the combinatorial space
|
||||
/// that only exists once a session is open: interleaved probe sends,
|
||||
/// valid/duplicate/late/unknown/wrong-token replies, peer and local
|
||||
/// close, bounded and unbounded timeout polling, and `u32` wire-sequence
|
||||
/// wraparound.
|
||||
///
|
||||
/// The reference [`Model`] tracks only state the public contract
|
||||
/// documents: open/closed(local)/closed(peer) state, the next wire
|
||||
/// sequence and total sent-packet counters (mirroring
|
||||
/// `SessionMachine::packets_sent`), which sequences are pending, timed
|
||||
/// out, or completed (this drives the exact
|
||||
/// `EchoReply`/`LateReply`/`DuplicateReply`/`Warning` classification
|
||||
/// documented on `ClientEvent`), each pending probe's timeout deadline
|
||||
/// (documented on `ClientEvent::EchoLoss::timeout_at`), and the highest
|
||||
/// accepted in-window sequence used for late-reply classification. It
|
||||
/// deliberately does *not* model the bounded FIFO eviction that
|
||||
/// `PendingMap`/`TimedOutMap`/`CompletedSet` apply once
|
||||
/// `max_pending_probes` is exceeded: that is a private capacity policy
|
||||
/// with its own focused unit tests in `probe.rs`, not part of this
|
||||
/// contract. Every generated case here uses a `max_pending_probes` far
|
||||
/// larger than the generated operation count, so capacity/eviction
|
||||
/// behavior never confounds the classification assertions made here.
|
||||
///
|
||||
/// `u32` sequence wraparound is exercised by seeding a session's
|
||||
/// starting wire sequence near `u32::MAX` through direct construction,
|
||||
/// the same way `open_machine` above builds an open session without
|
||||
/// running the handshake. The repository's `AGENTS.md` testing policy
|
||||
/// names "sequence wrap" as a sanctioned reason to construct state
|
||||
/// directly rather than drive billions of real sends.
|
||||
mod model_properties {
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::TestCaseError;
|
||||
|
||||
use super::*;
|
||||
use irtt_proto::{encode_echo_reply, ReceivedStats, StampAt};
|
||||
|
||||
const TOKEN: u64 = 0x1122_3344_5566_7788;
|
||||
const WRONG_TOKEN: u64 = 0x8877_6655_4433_2211;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(30);
|
||||
/// Far larger than the generated operation count (`1..=OP_LIMIT`),
|
||||
/// so pending/timed-out/completed bounded eviction never triggers;
|
||||
/// see the module doc comment.
|
||||
const MAX_PENDING_PROBES: usize = 4096;
|
||||
const OP_LIMIT: usize = 50;
|
||||
const CASES: u32 = 256;
|
||||
|
||||
fn model_config() -> ClientConfig {
|
||||
ClientConfig {
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Wall,
|
||||
probe_timeout: PROBE_TIMEOUT,
|
||||
max_pending_probes: MAX_PENDING_PROBES,
|
||||
run_mode: RunMode::Normal,
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an already-`Open` session directly, the same way `mod
|
||||
/// tests`'s `open_machine` does, starting its wire sequence at
|
||||
/// `start_seq` instead of always zero. See the module doc comment
|
||||
/// for why this bypasses the open handshake and for the sequence
|
||||
/// seeding rationale.
|
||||
fn open_machine_for_model(start_seq: u32) -> (SessionMachine, Params) {
|
||||
let config = model_config();
|
||||
let remote = "127.0.0.1:2112".parse().unwrap();
|
||||
let mut machine = SessionMachine::new(config, remote).unwrap();
|
||||
let params = machine.requested.clone();
|
||||
let negotiated = NegotiatedParams {
|
||||
params: params.clone(),
|
||||
restrictions: Vec::new(),
|
||||
};
|
||||
machine.state = MachineState::Open(Box::new(ActiveSession {
|
||||
token: TOKEN,
|
||||
negotiated,
|
||||
local_close_packet: encode_request(RequestToEncode::Close { token: TOKEN }, None)
|
||||
.unwrap()
|
||||
.into_boxed_slice(),
|
||||
next_wire_seq: start_seq,
|
||||
highest_received_seq: None,
|
||||
packets_sent: 0,
|
||||
pending: PendingMap::new(MAX_PENDING_PROBES),
|
||||
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
|
||||
completed: CompletedSet::new(MAX_PENDING_PROBES),
|
||||
}));
|
||||
(machine, params)
|
||||
}
|
||||
|
||||
fn build_echo_reply(params: &Params, token: u64, seq: u32, close: bool) -> Vec<u8> {
|
||||
let mut flags_value = flags::FLAG_REPLY;
|
||||
if close {
|
||||
flags_value |= flags::FLAG_CLOSE;
|
||||
}
|
||||
let reply = EchoReply {
|
||||
flags: flags_value,
|
||||
token,
|
||||
sequence: seq,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
timestamps: TimestampFields::default(),
|
||||
payload: Vec::new(),
|
||||
};
|
||||
encode_echo_reply(&reply, params, None).unwrap()
|
||||
}
|
||||
|
||||
/// Independently-authored mirror of the sequence-space half-window
|
||||
/// "after" comparison (RFC 1982 style), used only to predict
|
||||
/// expected late/in-window classification. Deliberately not a call
|
||||
/// into `super::sequence_is_after`/`sequence_is_before`, so a bug in
|
||||
/// the production comparator is not automatically invisible to this
|
||||
/// model.
|
||||
fn seq_after(candidate: u32, current: u32) -> bool {
|
||||
candidate != current && candidate.wrapping_sub(current) < (1 << 31)
|
||||
}
|
||||
|
||||
fn seq_before(candidate: u32, current: u32) -> bool {
|
||||
seq_after(current, candidate)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModelState {
|
||||
Open,
|
||||
ClosedLocal,
|
||||
ClosedPeer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExpectedEvent {
|
||||
EchoReply(u32),
|
||||
LateReply(u32),
|
||||
DuplicateReply(u32),
|
||||
WarnWrongToken,
|
||||
WarnUntracked,
|
||||
SessionClosed,
|
||||
}
|
||||
|
||||
/// The independent reference model. See the module doc comment for
|
||||
/// its deliberate boundary against `SessionMachine`'s private state.
|
||||
struct Model {
|
||||
state: ModelState,
|
||||
next_wire_seq: u32,
|
||||
packets_sent: u64,
|
||||
pending: VecDeque<(u32, Instant)>,
|
||||
timed_out: HashSet<u32>,
|
||||
completed: HashSet<u32>,
|
||||
highest_received_seq: Option<u32>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
fn new(start_seq: u32) -> Self {
|
||||
Self {
|
||||
state: ModelState::Open,
|
||||
next_wire_seq: start_seq,
|
||||
packets_sent: 0,
|
||||
pending: VecDeque::new(),
|
||||
timed_out: HashSet::new(),
|
||||
completed: HashSet::new(),
|
||||
highest_received_seq: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_remove(&mut self, seq: u32) -> bool {
|
||||
if let Some(pos) = self.pending.iter().position(|&(s, _)| s == seq) {
|
||||
self.pending.remove(pos);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_pending(&self) -> Vec<u32> {
|
||||
let mut v: Vec<u32> = self.pending.iter().map(|&(s, _)| s).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
fn sorted_timed_out(&self) -> Vec<u32> {
|
||||
let mut v: Vec<u32> = self.timed_out.iter().copied().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
fn sorted_completed(&self) -> Vec<u32> {
|
||||
let mut v: Vec<u32> = self.completed.iter().copied().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
/// Resolves a generated [`ReplyTarget`] to a concrete sequence
|
||||
/// number against the model's *current* membership. When the
|
||||
/// requested bucket is empty this falls back to the raw index as
|
||||
/// a literal (likely-unknown) sequence, so every `ReplyTarget`
|
||||
/// variant remains a valid, meaningful generator input
|
||||
/// regardless of what has happened so far.
|
||||
fn resolve(&self, target: ReplyTarget) -> u32 {
|
||||
fn pick(v: &[u32], i: u32) -> u32 {
|
||||
if v.is_empty() {
|
||||
i
|
||||
} else {
|
||||
v[(i as usize) % v.len()]
|
||||
}
|
||||
}
|
||||
match target {
|
||||
ReplyTarget::Pending(i) => pick(&self.sorted_pending(), i),
|
||||
ReplyTarget::TimedOut(i) => pick(&self.sorted_timed_out(), i),
|
||||
ReplyTarget::Completed(i) => pick(&self.sorted_completed(), i),
|
||||
ReplyTarget::Unknown(seq) => seq,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_highest(&mut self, seq: u32) {
|
||||
self.highest_received_seq = Some(match self.highest_received_seq {
|
||||
None => seq,
|
||||
Some(h) if seq_after(seq, h) => seq,
|
||||
Some(h) => h,
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_send(&mut self, seq: u32, timeout_at: Instant) {
|
||||
assert_eq!(
|
||||
seq, self.next_wire_seq,
|
||||
"model/machine wire sequence desync"
|
||||
);
|
||||
self.pending.push_back((seq, timeout_at));
|
||||
self.next_wire_seq = self.next_wire_seq.wrapping_add(1);
|
||||
self.packets_sent += 1;
|
||||
}
|
||||
|
||||
fn apply_poll(&mut self, now: Instant, limit: usize) -> (Vec<u32>, bool) {
|
||||
let mut expired = Vec::new();
|
||||
while expired.len() < limit {
|
||||
let Some(&(seq, timeout_at)) = self.pending.front() else {
|
||||
break;
|
||||
};
|
||||
if timeout_at > now {
|
||||
break;
|
||||
}
|
||||
self.pending.pop_front();
|
||||
self.timed_out.insert(seq);
|
||||
expired.push(seq);
|
||||
}
|
||||
let more_due = self
|
||||
.pending
|
||||
.front()
|
||||
.is_some_and(|&(_, timeout_at)| timeout_at <= now);
|
||||
(expired, more_due)
|
||||
}
|
||||
|
||||
/// Mirrors `SessionMachine::process_echo_reply`'s classification
|
||||
/// exactly (see that function's branches), mutating this model
|
||||
/// the same way. Returns the event kinds a correct
|
||||
/// implementation must produce, in order.
|
||||
fn reply(&mut self, seq: u32, wrong_token: bool, close: bool) -> Vec<ExpectedEvent> {
|
||||
if wrong_token {
|
||||
// A wrong-token reply is rejected before the close flag
|
||||
// is ever inspected, so `close` has no effect here.
|
||||
return vec![ExpectedEvent::WarnWrongToken];
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
if self.pending_remove(seq) {
|
||||
let is_late = self
|
||||
.highest_received_seq
|
||||
.is_some_and(|h| seq_before(seq, h));
|
||||
self.update_highest(seq);
|
||||
self.completed.insert(seq);
|
||||
events.push(if is_late {
|
||||
ExpectedEvent::LateReply(seq)
|
||||
} else {
|
||||
ExpectedEvent::EchoReply(seq)
|
||||
});
|
||||
} else if self.completed.contains(&seq) {
|
||||
self.update_highest(seq);
|
||||
events.push(ExpectedEvent::DuplicateReply(seq));
|
||||
} else if self.timed_out.remove(&seq) {
|
||||
self.update_highest(seq);
|
||||
self.completed.insert(seq);
|
||||
events.push(ExpectedEvent::LateReply(seq));
|
||||
} else if self
|
||||
.highest_received_seq
|
||||
.is_some_and(|h| seq_before(seq, h))
|
||||
{
|
||||
events.push(ExpectedEvent::LateReply(seq));
|
||||
} else {
|
||||
events.push(ExpectedEvent::WarnUntracked);
|
||||
}
|
||||
if close {
|
||||
self.state = ModelState::ClosedPeer;
|
||||
events.push(ExpectedEvent::SessionClosed);
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_real(events: &[ClientEvent]) -> Vec<ExpectedEvent> {
|
||||
events
|
||||
.iter()
|
||||
.map(|event| match event {
|
||||
ClientEvent::EchoReply { seq, .. } => ExpectedEvent::EchoReply(*seq),
|
||||
ClientEvent::LateReply { seq, .. } => ExpectedEvent::LateReply(*seq),
|
||||
ClientEvent::DuplicateReply { seq, .. } => ExpectedEvent::DuplicateReply(*seq),
|
||||
ClientEvent::Warning {
|
||||
kind: WarningKind::WrongToken,
|
||||
..
|
||||
} => ExpectedEvent::WarnWrongToken,
|
||||
ClientEvent::Warning {
|
||||
kind: WarningKind::UntrackedReply,
|
||||
..
|
||||
} => ExpectedEvent::WarnUntracked,
|
||||
ClientEvent::SessionClosed { .. } => ExpectedEvent::SessionClosed,
|
||||
other => panic!("unexpected event from an open session: {other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_already_closed<T: std::fmt::Debug>(result: &Result<T, ClientError>) -> bool {
|
||||
matches!(result, Err(ClientError::AlreadyClosed))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Observable {
|
||||
is_open: bool,
|
||||
is_terminal: bool,
|
||||
is_peer_closed: bool,
|
||||
packets_sent: u64,
|
||||
pending_is_empty: bool,
|
||||
}
|
||||
|
||||
fn observe(machine: &SessionMachine) -> Observable {
|
||||
Observable {
|
||||
is_open: machine.is_open(),
|
||||
is_terminal: machine.is_terminal(),
|
||||
is_peer_closed: machine.is_peer_closed(),
|
||||
packets_sent: machine.packets_sent(),
|
||||
pending_is_empty: machine.pending_is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_generic_invariants(
|
||||
machine: &SessionMachine,
|
||||
model: &Model,
|
||||
) -> Result<(), TestCaseError> {
|
||||
prop_assert_eq!(machine.is_open(), model.state == ModelState::Open);
|
||||
prop_assert_eq!(
|
||||
machine.is_terminal(),
|
||||
matches!(
|
||||
model.state,
|
||||
ModelState::ClosedLocal | ModelState::ClosedPeer
|
||||
)
|
||||
);
|
||||
prop_assert_eq!(
|
||||
machine.is_peer_closed(),
|
||||
model.state == ModelState::ClosedPeer
|
||||
);
|
||||
prop_assert_eq!(machine.packets_sent(), model.packets_sent);
|
||||
if model.state == ModelState::Open {
|
||||
prop_assert_eq!(machine.pending_is_empty(), model.pending.is_empty());
|
||||
} else {
|
||||
prop_assert!(machine.pending_is_empty());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drives the full real send transaction: prepare, preflight,
|
||||
/// finalize, commit. Mirrors how `Client`/`AsyncClient` drive
|
||||
/// `SessionMachine` around a real socket send, using `now` for both
|
||||
/// the pre-send anchor and the post-send `sent_at` sample since no
|
||||
/// real send elapses time here.
|
||||
fn do_send(
|
||||
machine: &mut SessionMachine,
|
||||
now: ClientTimestamp,
|
||||
) -> Result<ProbeSent, ClientError> {
|
||||
let prepared = machine
|
||||
.prepare_probe()?
|
||||
.expect("prepare_probe always returns Some when Ok");
|
||||
let preflight = machine.preflight_probe_commit(&prepared)?;
|
||||
let commit = machine.finalize_probe_commit(preflight, now)?;
|
||||
let bytes = prepared.bytes.len();
|
||||
Ok(machine.commit_probe_sent(commit, now, bytes))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ReplyTarget {
|
||||
Pending(u32),
|
||||
TimedOut(u32),
|
||||
Completed(u32),
|
||||
Unknown(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
Send,
|
||||
StaleProbe,
|
||||
PrepareAbandon,
|
||||
Reply {
|
||||
target: ReplyTarget,
|
||||
wrong_token: bool,
|
||||
close: bool,
|
||||
},
|
||||
PollTimeouts,
|
||||
PollTimeoutsBounded(u8),
|
||||
AdvanceClock(u16),
|
||||
LocalClose,
|
||||
ReopenAttempt,
|
||||
}
|
||||
|
||||
fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
let reply_target = prop_oneof![
|
||||
any::<u32>().prop_map(ReplyTarget::Pending),
|
||||
any::<u32>().prop_map(ReplyTarget::TimedOut),
|
||||
any::<u32>().prop_map(ReplyTarget::Completed),
|
||||
any::<u32>().prop_map(ReplyTarget::Unknown),
|
||||
];
|
||||
prop_oneof![
|
||||
4 => Just(Op::Send),
|
||||
1 => Just(Op::StaleProbe),
|
||||
1 => Just(Op::PrepareAbandon),
|
||||
5 => (reply_target, any::<bool>(), any::<bool>()).prop_map(
|
||||
|(target, wrong_token, close)| Op::Reply {
|
||||
target,
|
||||
wrong_token,
|
||||
close,
|
||||
}
|
||||
),
|
||||
2 => Just(Op::PollTimeouts),
|
||||
1 => (0u8..=6).prop_map(Op::PollTimeoutsBounded),
|
||||
4 => (0u16..=120).prop_map(Op::AdvanceClock),
|
||||
1 => Just(Op::LocalClose),
|
||||
1 => Just(Op::ReopenAttempt),
|
||||
]
|
||||
}
|
||||
|
||||
/// Biased toward `0` (the common case) and toward the last few
|
||||
/// values before `u32::MAX` (so a handful of sends from here cross
|
||||
/// the wraparound boundary), with a uniformly random third case for
|
||||
/// general coverage.
|
||||
fn start_seq_strategy() -> impl Strategy<Value = u32> {
|
||||
prop_oneof![
|
||||
2 => Just(0u32),
|
||||
3 => (u32::MAX - 4)..=u32::MAX,
|
||||
2 => any::<u32>(),
|
||||
]
|
||||
}
|
||||
|
||||
fn apply_op(
|
||||
machine: &mut SessionMachine,
|
||||
model: &mut Model,
|
||||
params: &Params,
|
||||
now: &mut ClientTimestamp,
|
||||
op: Op,
|
||||
) -> Result<(), TestCaseError> {
|
||||
match op {
|
||||
Op::Send => {
|
||||
let result = do_send(machine, *now);
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
let sent = result.expect(
|
||||
"open session send should succeed within chosen capacity bounds",
|
||||
);
|
||||
prop_assert_eq!(sent.seq, model.next_wire_seq);
|
||||
let timeout_at = now
|
||||
.mono
|
||||
.checked_add(PROBE_TIMEOUT)
|
||||
.expect("no overflow at test timescales");
|
||||
model.apply_send(sent.seq, timeout_at);
|
||||
}
|
||||
_ => prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
),
|
||||
}
|
||||
}
|
||||
Op::StaleProbe => {
|
||||
if model.state != ModelState::Open {
|
||||
let result = machine.prepare_probe();
|
||||
prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
);
|
||||
} else {
|
||||
let prepared_a = machine
|
||||
.prepare_probe()
|
||||
.unwrap()
|
||||
.expect("open session prepares probes");
|
||||
prop_assert_eq!(prepared_a.seq, model.next_wire_seq);
|
||||
|
||||
let sent = do_send(machine, *now).expect(
|
||||
"intervening send should succeed within chosen capacity bounds",
|
||||
);
|
||||
prop_assert_eq!(sent.seq, prepared_a.seq);
|
||||
let timeout_at = now
|
||||
.mono
|
||||
.checked_add(PROBE_TIMEOUT)
|
||||
.expect("no overflow at test timescales");
|
||||
model.apply_send(sent.seq, timeout_at);
|
||||
|
||||
match machine.preflight_probe_commit(&prepared_a) {
|
||||
Err(ClientError::StalePreparedProbe {
|
||||
prepared_seq,
|
||||
next_wire_seq,
|
||||
}) => {
|
||||
prop_assert_eq!(prepared_seq, prepared_a.seq);
|
||||
prop_assert_eq!(next_wire_seq, model.next_wire_seq);
|
||||
}
|
||||
other => {
|
||||
prop_assert!(false, "expected StalePreparedProbe, got {:?}", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::PrepareAbandon => {
|
||||
let before = observe(machine);
|
||||
let result = machine.prepare_probe();
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
let prepared = result.unwrap().expect("open session prepares probes");
|
||||
prop_assert_eq!(prepared.seq, model.next_wire_seq);
|
||||
prop_assert!(!prepared.bytes.is_empty());
|
||||
}
|
||||
_ => prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
),
|
||||
}
|
||||
prop_assert_eq!(observe(machine), before);
|
||||
}
|
||||
Op::Reply {
|
||||
target,
|
||||
wrong_token,
|
||||
close,
|
||||
} => {
|
||||
let seq = model.resolve(target);
|
||||
let token = if wrong_token { WRONG_TOKEN } else { TOKEN };
|
||||
let packet = build_echo_reply(params, token, seq, close);
|
||||
let result =
|
||||
machine.process_received_echo_packet(&packet, *now, ReceiveMeta::default());
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
let events = result.expect("open session processes echo packets");
|
||||
let expected = model.reply(seq, wrong_token, close);
|
||||
prop_assert_eq!(classify_real(&events), expected);
|
||||
}
|
||||
_ => prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
),
|
||||
}
|
||||
}
|
||||
Op::PollTimeouts => {
|
||||
let result = machine.poll_timeouts_at(now.mono);
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
let events = result.expect("open session polls succeed");
|
||||
let (expired, _more_due) = model.apply_poll(now.mono, usize::MAX);
|
||||
let real: Vec<u32> = events
|
||||
.iter()
|
||||
.map(|event| match event {
|
||||
ClientEvent::EchoLoss { seq, .. } => *seq,
|
||||
other => {
|
||||
panic!("unexpected event from poll_timeouts_at: {other:?}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
prop_assert_eq!(real, expired);
|
||||
}
|
||||
_ => prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
),
|
||||
}
|
||||
}
|
||||
Op::PollTimeoutsBounded(limit) => {
|
||||
let result = machine.poll_timeouts_bounded_at(now.mono, limit as usize);
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
let batch = result.expect("open session polls succeed");
|
||||
let (expired, more_due) = model.apply_poll(now.mono, limit as usize);
|
||||
let real: Vec<u32> = batch
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| match event {
|
||||
ClientEvent::EchoLoss { seq, .. } => *seq,
|
||||
other => panic!(
|
||||
"unexpected event from poll_timeouts_bounded_at: {other:?}"
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
prop_assert_eq!(real, expired);
|
||||
prop_assert_eq!(batch.more_due, more_due);
|
||||
}
|
||||
_ => prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
),
|
||||
}
|
||||
}
|
||||
Op::AdvanceClock(millis) => {
|
||||
let delta = Duration::from_millis(u64::from(millis));
|
||||
now.mono += delta;
|
||||
now.wall += delta;
|
||||
}
|
||||
Op::LocalClose => match model.state {
|
||||
ModelState::Open => {
|
||||
let PreparedClose { commit, .. } = machine
|
||||
.prepare_close()
|
||||
.expect("open session can prepare close");
|
||||
let event = machine.commit_local_close(commit, *now);
|
||||
let is_session_closed = matches!(event, ClientEvent::SessionClosed { .. });
|
||||
prop_assert!(is_session_closed);
|
||||
model.state = ModelState::ClosedLocal;
|
||||
}
|
||||
_ => {
|
||||
let result = machine.prepare_close();
|
||||
prop_assert!(
|
||||
is_already_closed(&result),
|
||||
"expected AlreadyClosed, got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
},
|
||||
Op::ReopenAttempt => {
|
||||
let before = observe(machine);
|
||||
let result = machine.prepare_open_request();
|
||||
match model.state {
|
||||
ModelState::Open => {
|
||||
prop_assert!(matches!(result, Err(ClientError::AlreadyOpen)));
|
||||
}
|
||||
ModelState::ClosedLocal | ModelState::ClosedPeer => {
|
||||
prop_assert!(is_already_closed(&result));
|
||||
}
|
||||
}
|
||||
prop_assert_eq!(observe(machine), before);
|
||||
}
|
||||
}
|
||||
check_generic_invariants(machine, model)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(CASES))]
|
||||
|
||||
/// Drives a generated sequence of operations through both a real,
|
||||
/// already-`Open` `SessionMachine` and an independent reference
|
||||
/// [`Model`], asserting after every operation that the observable
|
||||
/// state (open/terminal/peer-closed/packets-sent/pending-empty)
|
||||
/// matches, every error returned matches the model's
|
||||
/// expectation, and every event/classification produced for a
|
||||
/// probe send, a reply, or a timeout poll matches the model's
|
||||
/// prediction. No operation should ever panic.
|
||||
#[test]
|
||||
fn open_session_matches_reference_model(
|
||||
start_seq in start_seq_strategy(),
|
||||
ops in proptest::collection::vec(op_strategy(), 1..=OP_LIMIT),
|
||||
) {
|
||||
let (mut machine, params) = open_machine_for_model(start_seq);
|
||||
let mut model = Model::new(start_seq);
|
||||
let mut now = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
|
||||
check_generic_invariants(&machine, &model)?;
|
||||
for op in ops {
|
||||
apply_op(&mut machine, &mut model, ¶ms, &mut now, op)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "protocol"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
@@ -16,3 +16,6 @@ hmac = "0.13"
|
||||
md-5 = "0.11"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.20"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
@@ -0,0 +1,30 @@
|
||||
# irtt-proto
|
||||
|
||||
Low-level wire encoding and decoding for the IRTT-compatible protocol: request
|
||||
and reply structs, flags, negotiable parameters, varint/zigzag primitives, and
|
||||
HMAC-MD5 packet authentication. No sockets, no runtime, no session state.
|
||||
|
||||
## Who should use this
|
||||
|
||||
Most applications should not depend on this crate directly. Use
|
||||
[`irtt-client`](https://docs.rs/irtt-client) to run client sessions or
|
||||
[`irtt-server`](https://docs.rs/irtt-server) to run a server; both build on
|
||||
`irtt-proto` and provide session lifecycle, negotiation, and validated
|
||||
configuration on top of it.
|
||||
|
||||
Reach for `irtt-proto` directly only if you are implementing another
|
||||
IRTT-compatible client or server from scratch and need the raw packet layer.
|
||||
|
||||
Field values mirror the wire format closely rather than expressing policy:
|
||||
`Params::decode` rejects malformed input, but direct construction plus
|
||||
`Params::encode` performs no additional validation — callers are responsible
|
||||
for validating values before sending them.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-proto](https://docs.rs/irtt-proto).
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -6,6 +6,10 @@
|
||||
//! crates such as `irtt-client` are responsible for session behavior and
|
||||
//! user-facing validation.
|
||||
//!
|
||||
//! Most applications should depend on `irtt-client` or `irtt-server` instead
|
||||
//! of this crate directly; use `irtt-proto` only when implementing another
|
||||
//! IRTT-compatible client or server that needs the raw packet layer itself.
|
||||
//!
|
||||
//! [`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`]
|
||||
|
||||
@@ -764,4 +764,340 @@ mod tests {
|
||||
let encoded = [99];
|
||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
|
||||
}
|
||||
|
||||
mod properties {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn received_stats_strategy() -> impl Strategy<Value = ReceivedStats> {
|
||||
prop_oneof![
|
||||
Just(ReceivedStats::None),
|
||||
Just(ReceivedStats::Count),
|
||||
Just(ReceivedStats::Window),
|
||||
Just(ReceivedStats::Both),
|
||||
]
|
||||
}
|
||||
|
||||
fn stamp_at_strategy() -> impl Strategy<Value = StampAt> {
|
||||
prop_oneof![
|
||||
Just(StampAt::None),
|
||||
Just(StampAt::Send),
|
||||
Just(StampAt::Receive),
|
||||
Just(StampAt::Both),
|
||||
Just(StampAt::Midpoint),
|
||||
]
|
||||
}
|
||||
|
||||
/// A `Clock` valid for direct construction, including `Unspecified`
|
||||
/// (wire omission). Never produces an explicit zero on the wire; see
|
||||
/// `explicit_clock_strategy` for that.
|
||||
fn clock_strategy() -> impl Strategy<Value = Clock> {
|
||||
prop_oneof![
|
||||
Just(Clock::Unspecified),
|
||||
Just(Clock::Wall),
|
||||
Just(Clock::Monotonic),
|
||||
Just(Clock::Both),
|
||||
]
|
||||
}
|
||||
|
||||
/// A `Clock` value valid to encode *explicitly* on the wire (tag 7).
|
||||
/// Excludes `Unspecified`/zero, which is only ever reached by omission.
|
||||
fn explicit_clock_value_strategy() -> impl Strategy<Value = i64> {
|
||||
1_i64..=3
|
||||
}
|
||||
|
||||
fn server_fill_strategy() -> impl Strategy<Value = Option<ServerFill>> {
|
||||
prop_oneof![
|
||||
Just(None),
|
||||
proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some(
|
||||
ServerFill {
|
||||
value: chars.into_iter().collect(),
|
||||
}
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn params_strategy() -> impl Strategy<Value = Params> {
|
||||
(
|
||||
any::<i64>(),
|
||||
any::<i64>(),
|
||||
any::<i64>(),
|
||||
any::<i64>(),
|
||||
any::<i64>(),
|
||||
received_stats_strategy(),
|
||||
stamp_at_strategy(),
|
||||
clock_strategy(),
|
||||
server_fill_strategy(),
|
||||
)
|
||||
.prop_map(
|
||||
|(
|
||||
protocol_version,
|
||||
duration_ns,
|
||||
interval_ns,
|
||||
length,
|
||||
dscp,
|
||||
received_stats,
|
||||
stamp_at,
|
||||
clock,
|
||||
server_fill,
|
||||
)| Params {
|
||||
protocol_version,
|
||||
duration_ns,
|
||||
interval_ns,
|
||||
length,
|
||||
received_stats,
|
||||
stamp_at,
|
||||
clock,
|
||||
dscp,
|
||||
server_fill,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec<u8>) {
|
||||
varint::encode_uvarint(tag, out);
|
||||
varint::encode_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_tagged_server_fill(value: &str, out: &mut Vec<u8>) {
|
||||
varint::encode_uvarint(9, out);
|
||||
varint::encode_uvarint(value.len() as u64, out);
|
||||
out.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn valid_params_round_trip(params in params_strategy()) {
|
||||
let encoded = params.encode();
|
||||
let decoded = Params::decode(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_param_presence_matches_omission_rules(params in params_strategy()) {
|
||||
let encoded = params.encode();
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(&decoded.params, ¶ms);
|
||||
|
||||
prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0);
|
||||
prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0);
|
||||
prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0);
|
||||
prop_assert_eq!(decoded.presence.length, params.length != 0);
|
||||
prop_assert_eq!(decoded.presence.dscp, params.dscp != 0);
|
||||
prop_assert_eq!(
|
||||
decoded.presence.received_stats,
|
||||
params.received_stats != ReceivedStats::None
|
||||
);
|
||||
prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None);
|
||||
prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified);
|
||||
prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_protocol_version_is_last_wins(first: i64, second: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(1, first, &mut encoded);
|
||||
encode_tagged_int(1, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.protocol_version, second);
|
||||
prop_assert!(decoded.presence.protocol_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_duration_ns_is_last_wins(first: i64, second: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(2, first, &mut encoded);
|
||||
encode_tagged_int(2, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.duration_ns, second);
|
||||
prop_assert!(decoded.presence.duration_ns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_interval_ns_is_last_wins(first: i64, second: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(3, first, &mut encoded);
|
||||
encode_tagged_int(3, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.interval_ns, second);
|
||||
prop_assert!(decoded.presence.interval_ns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_length_is_last_wins(first: i64, second: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(4, first, &mut encoded);
|
||||
encode_tagged_int(4, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.length, second);
|
||||
prop_assert!(decoded.presence.length);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(5, first, &mut encoded);
|
||||
encode_tagged_int(5, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap());
|
||||
prop_assert!(decoded.presence.received_stats);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(6, first, &mut encoded);
|
||||
encode_tagged_int(6, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap());
|
||||
prop_assert!(decoded.presence.stamp_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_clock_is_last_wins(
|
||||
first in explicit_clock_value_strategy(),
|
||||
second in explicit_clock_value_strategy(),
|
||||
) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(7, first, &mut encoded);
|
||||
encode_tagged_int(7, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap());
|
||||
prop_assert!(decoded.presence.clock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_dscp_is_last_wins(first: i64, second: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_int(8, first, &mut encoded);
|
||||
encode_tagged_int(8, second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded.params.dscp, second);
|
||||
prop_assert!(decoded.presence.dscp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_server_fill_is_last_wins(
|
||||
first in proptest::collection::vec(proptest::char::any(), 0..=8)
|
||||
.prop_map(|chars| chars.into_iter().collect::<String>()),
|
||||
second in proptest::collection::vec(proptest::char::any(), 0..=8)
|
||||
.prop_map(|chars| chars.into_iter().collect::<String>()),
|
||||
) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_tagged_server_fill(&first, &mut encoded);
|
||||
encode_tagged_server_fill(&second, &mut encoded);
|
||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||
prop_assert_eq!(
|
||||
decoded.params.server_fill,
|
||||
Some(ServerFill { value: second })
|
||||
);
|
||||
prop_assert!(decoded.presence.server_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scalar_tag_prefix_does_not_disturb_known_params(
|
||||
params in params_strategy(),
|
||||
unknown_tag in 10_u64..=u16::MAX as u64,
|
||||
unknown_value: u64,
|
||||
) {
|
||||
let mut prefixed = Vec::new();
|
||||
varint::encode_uvarint(unknown_tag, &mut prefixed);
|
||||
varint::encode_uvarint(unknown_value, &mut prefixed);
|
||||
prefixed.extend_from_slice(¶ms.encode());
|
||||
|
||||
let decoded = Params::decode(&prefixed).unwrap();
|
||||
prop_assert_eq!(decoded, params.clone());
|
||||
|
||||
let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap();
|
||||
let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence;
|
||||
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scalar_tag_suffix_does_not_disturb_known_params(
|
||||
params in params_strategy(),
|
||||
unknown_tag in 10_u64..=u16::MAX as u64,
|
||||
unknown_value: u64,
|
||||
) {
|
||||
let mut suffixed = params.encode();
|
||||
varint::encode_uvarint(unknown_tag, &mut suffixed);
|
||||
varint::encode_uvarint(unknown_value, &mut suffixed);
|
||||
|
||||
let decoded = Params::decode(&suffixed).unwrap();
|
||||
prop_assert_eq!(decoded, params.clone());
|
||||
|
||||
let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap();
|
||||
let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence;
|
||||
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
|
||||
}
|
||||
}
|
||||
|
||||
/// Omitted-vs-explicit-zero presence, for the fields where explicit
|
||||
/// zero is valid on the wire. `Clock` is deliberately excluded: an
|
||||
/// explicit zero there is rejected outright, not merely "present with
|
||||
/// the default value" (see `explicit_clock_zero_is_rejected`).
|
||||
#[test]
|
||||
fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() {
|
||||
let scalar_tags = [1_u64, 2, 3, 4, 8];
|
||||
for tag in scalar_tags {
|
||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
||||
let mut explicit_zero = Vec::new();
|
||||
encode_tagged_int(tag, 0, &mut explicit_zero);
|
||||
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
|
||||
|
||||
assert_eq!(omitted.params, explicit.params);
|
||||
assert_eq!(explicit.params, Params::default());
|
||||
|
||||
let (omitted_presence, explicit_presence) = match tag {
|
||||
1 => (
|
||||
omitted.presence.protocol_version,
|
||||
explicit.presence.protocol_version,
|
||||
),
|
||||
2 => (omitted.presence.duration_ns, explicit.presence.duration_ns),
|
||||
3 => (omitted.presence.interval_ns, explicit.presence.interval_ns),
|
||||
4 => (omitted.presence.length, explicit.presence.length),
|
||||
8 => (omitted.presence.dscp, explicit.presence.dscp),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(!omitted_presence);
|
||||
assert!(explicit_presence);
|
||||
}
|
||||
|
||||
let enum_tags = [5_u64, 6];
|
||||
for tag in enum_tags {
|
||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
||||
let mut explicit_zero = Vec::new();
|
||||
encode_tagged_int(tag, 0, &mut explicit_zero);
|
||||
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
|
||||
|
||||
assert_eq!(omitted.params, explicit.params);
|
||||
|
||||
let (omitted_presence, explicit_presence) = match tag {
|
||||
5 => (
|
||||
omitted.presence.received_stats,
|
||||
explicit.presence.received_stats,
|
||||
),
|
||||
6 => (omitted.presence.stamp_at, explicit.presence.stamp_at),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(!omitted_presence);
|
||||
assert!(explicit_presence);
|
||||
}
|
||||
|
||||
// `Clock` deliberately breaks the pattern above: explicit zero is
|
||||
// rejected rather than accepted-but-present.
|
||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
||||
assert_eq!(omitted.params.clock, Clock::Unspecified);
|
||||
assert!(!omitted.presence.clock);
|
||||
let mut explicit_clock_zero = Vec::new();
|
||||
encode_tagged_int(7, 0, &mut explicit_clock_zero);
|
||||
assert_eq!(
|
||||
Params::decode_with_presence(&explicit_clock_zero),
|
||||
Err(ProtoError::InvalidEnum {
|
||||
name: "Clock",
|
||||
value: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,4 +112,43 @@ mod tests {
|
||||
encode_uvarint(24, &mut tag);
|
||||
assert_eq!(tag, [0x18]);
|
||||
}
|
||||
|
||||
mod properties {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn uvarint_round_trips(value: u64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_uvarint(value, &mut encoded);
|
||||
prop_assert!(!encoded.is_empty());
|
||||
prop_assert!(encoded.len() <= 10);
|
||||
let (decoded, used) = decode_uvarint(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded, value);
|
||||
prop_assert_eq!(used, encoded.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_varint_round_trips(value: i64) {
|
||||
let mut encoded = Vec::new();
|
||||
encode_varint(value, &mut encoded);
|
||||
prop_assert!(!encoded.is_empty());
|
||||
prop_assert!(encoded.len() <= 10);
|
||||
let (decoded, used) = decode_varint(&encoded).unwrap();
|
||||
prop_assert_eq!(decoded, value);
|
||||
prop_assert_eq!(used, encoded.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_round_trips_from_signed(value: i64) {
|
||||
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_round_trips_from_unsigned(value: u64) {
|
||||
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "irtt-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
description = "IRTT-compatible Rust server library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "server"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
@@ -23,3 +23,13 @@ tokio = { version = "1.53.1", default-features = false, features = ["rt", "net",
|
||||
# source-selection path here; elsewhere a wildcard bind is refused.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))'.dependencies]
|
||||
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"] }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
|
||||
[[example]]
|
||||
name = "basic_server"
|
||||
|
||||
[[example]]
|
||||
name = "multi_listener"
|
||||
@@ -0,0 +1,41 @@
|
||||
# irtt-server
|
||||
|
||||
Reusable, Tokio-native library for running an IRTT-compatible UDP server:
|
||||
open negotiation, the bounded session table, echo processing, per-session
|
||||
rate limiting, idle and max-duration expiry, and reply construction.
|
||||
|
||||
## Looking for the CLI?
|
||||
|
||||
If you just want to run a server from the command line, use the
|
||||
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
|
||||
which installs the `irtt-server` binary. This crate is the library that
|
||||
binary is built on.
|
||||
|
||||
## Structure
|
||||
|
||||
- `Server` — the one-listener primitive: one UDP socket, one deterministic
|
||||
`ServerCore`, one sequential receive/reply loop.
|
||||
- `ServerSet` — supervises one or more `Server`s as a single service: binds
|
||||
them all or none, runs each in its own task, and fans one shutdown out to
|
||||
all of them.
|
||||
|
||||
Unlike `irtt-client`, Tokio is not optional here — this crate is intentionally
|
||||
Tokio-native, with no blocking or alternate-runtime variant.
|
||||
|
||||
A wildcard listener (`0.0.0.0` / `[::]`) recovers each request's local
|
||||
destination and replies from it, so a client on a second local address is
|
||||
answered from the address it actually contacted. That path is implemented
|
||||
for Linux, macOS, and FreeBSD; a wildcard bind is refused at construction on
|
||||
other targets rather than served incorrectly. Explicit-address listeners are
|
||||
unaffected everywhere.
|
||||
|
||||
See `examples/` in the repository for runnable examples.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-server](https://docs.rs/irtt-server).
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Basic single-listener server example.
|
||||
//!
|
||||
//! Binds one [`Server`] on an ephemeral loopback port, serves it for a short
|
||||
//! fixed window, and shuts down. A real deployment would normally bind a
|
||||
//! fixed port and run until an external shutdown signal.
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-server --example basic_server
|
||||
//! ```
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||
let config = ServerConfig::default();
|
||||
|
||||
let mut server = match Server::bind(bind_addr, config).await {
|
||||
Ok(server) => server,
|
||||
Err(err) => {
|
||||
eprintln!("failed to bind server: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"listening on {}",
|
||||
server
|
||||
.local_addr()
|
||||
.expect("bound socket has a local address")
|
||||
);
|
||||
|
||||
let result = server.run(tokio::time::sleep(Duration::from_secs(2))).await;
|
||||
if let Err(err) = result {
|
||||
eprintln!("server stopped with an error: {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Multi-listener example using [`ServerSet`].
|
||||
//!
|
||||
//! `ServerSet` binds several independent [`Server`] listeners as one service:
|
||||
//! all-or-nothing bind, one shutdown fanned out to every listener, and the
|
||||
//! whole set fails if any listener does. Each listener still gets its own
|
||||
//! socket, session table, and tokens — a token issued by one is unknown at
|
||||
//! the other. This is what the `irtt-rs` server CLI always runs through, even
|
||||
//! for a single bind.
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-server --example multi_listener
|
||||
//! ```
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_server::{ServerConfig, ServerSet};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let addrs = [
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
];
|
||||
let config = ServerConfig::default();
|
||||
|
||||
let set = match ServerSet::bind(addrs, config).await {
|
||||
Ok(set) => set,
|
||||
Err(err) => {
|
||||
eprintln!("failed to bind listener set: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("listening on {:?}", set.local_addrs());
|
||||
|
||||
let result = set.run(tokio::time::sleep(Duration::from_secs(2))).await;
|
||||
if let Err(err) = result {
|
||||
eprintln!("listener set stopped with an error: {err}");
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ fn parse_hex_pattern(body: &str) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
digits
|
||||
.chunks_exact(2)
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|pair| Some(hex_digit(pair[0])? << 4 | hex_digit(pair[1])?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
//! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not
|
||||
//! per process.
|
||||
//!
|
||||
//! See `examples/` in the repository for a runnable single-listener and
|
||||
//! multi-listener example.
|
||||
//!
|
||||
//! # Rejection is silence
|
||||
//!
|
||||
//! The protocol defines no error reply, no reset and no NACK. Malformed,
|
||||
@@ -129,4 +132,4 @@ pub use config::{
|
||||
pub use core::{OutboundDatagram, ServerCore};
|
||||
pub use error::ServerError;
|
||||
pub use runtime::{Server, ServerRuntimeError};
|
||||
pub use set::{ServerSet, ServerSetError};
|
||||
pub use set::{address_family_available, ServerSet, ServerSetError};
|
||||
@@ -109,6 +109,28 @@ impl Server {
|
||||
socket: UdpSocket,
|
||||
config: ServerConfig,
|
||||
) -> Result<Self, ServerRuntimeError> {
|
||||
Self::with_core(socket, ServerCore::new(config))
|
||||
}
|
||||
|
||||
/// Test-only seam: builds a listener around an already-constructed core
|
||||
/// instead of one this method would build from a [`ServerConfig`].
|
||||
///
|
||||
/// This exists so a unit test can substitute a scripted
|
||||
/// [`TokenSource`](crate::token::TokenSource) and make one listener in a
|
||||
/// [`ServerSet`](crate::ServerSet) fail deterministically after it has
|
||||
/// already bound and answered real traffic — the runtime otherwise offers
|
||||
/// no way to make a core failure happen on command without OS-level
|
||||
/// socket sabotage. Every other code path here — wildcard setup, kernel
|
||||
/// timestamp configuration — is unchanged from [`Server::from_socket`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_socket_with_core(
|
||||
socket: UdpSocket,
|
||||
core: ServerCore,
|
||||
) -> Result<Self, ServerRuntimeError> {
|
||||
Self::with_core(socket, core)
|
||||
}
|
||||
|
||||
fn with_core(socket: UdpSocket, core: ServerCore) -> Result<Self, ServerRuntimeError> {
|
||||
let addr = socket
|
||||
.local_addr()
|
||||
.map_err(|source| ServerRuntimeError::LocalAddr { source })?;
|
||||
@@ -131,7 +153,7 @@ impl Server {
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
core: ServerCore::new(config),
|
||||
core,
|
||||
recv_buffer: vec![0; RECEIVE_BUFFER_LEN],
|
||||
listener_is_ipv4: marks_with_ipv4_option(addr),
|
||||
select_reply_source,
|
||||
|
||||
@@ -218,6 +218,40 @@ impl ServerSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `addr`'s address family has any local support on this host at
|
||||
/// all, independent of whether this particular address or port can be
|
||||
/// bound.
|
||||
///
|
||||
/// A [`ServerSetError::ListenerSetup`] failure can mean many different
|
||||
/// things — the port is already in use, permission is denied, this target
|
||||
/// has no safe wildcard reply-source path (see [`ServerRuntimeError`]) — and
|
||||
/// only one of them, the address family itself having no local support at
|
||||
/// all (for example IPv6 disabled on the host), is safe for a caller to work
|
||||
/// around by silently trying a different address instead. This checks only
|
||||
/// that, by actually binding the wildcard address of `addr`'s family on an
|
||||
/// ephemeral port: a bare socket of an unsupported family can still
|
||||
/// construct successfully on some systems (Linux administratively disables
|
||||
/// IPv6 per-interface, so `AF_INET6` sockets keep working while every bind
|
||||
/// fails with `EADDRNOTAVAIL`), so construction alone is not a reliable
|
||||
/// signal — only a real bind attempt is.
|
||||
#[must_use]
|
||||
pub fn address_family_available(addr: SocketAddr) -> bool {
|
||||
let wildcard = if addr.is_ipv6() {
|
||||
SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0))
|
||||
} else {
|
||||
SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0))
|
||||
};
|
||||
let domain = if addr.is_ipv6() {
|
||||
Domain::IPV6
|
||||
} else {
|
||||
Domain::IPV4
|
||||
};
|
||||
let Ok(socket) = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) else {
|
||||
return false;
|
||||
};
|
||||
socket.bind(&SockAddr::from(wildcard)).is_ok()
|
||||
}
|
||||
|
||||
/// Binds one listener, pre-configuring the socket where the bind itself depends
|
||||
/// on it.
|
||||
///
|
||||
@@ -323,7 +357,10 @@ pub enum ServerSetError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::{token::TokenSource, ServerCore, ServerError};
|
||||
|
||||
/// Which listener of a requested set has to be bound IPv6-only.
|
||||
///
|
||||
@@ -409,4 +446,162 @@ mod tests {
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
|
||||
/// A token source that draws one value successfully and then fails every
|
||||
/// later draw.
|
||||
///
|
||||
/// This is the same seam the core's own tests already use to make token
|
||||
/// allocation fail deterministically (see [`crate::token::TokenSource`]
|
||||
/// and its `ScriptedTokens::failing`), specialized here so the *first*
|
||||
/// open still succeeds: the listener built from it answers one real
|
||||
/// exchange exactly like a healthy listener would, and only a second open
|
||||
/// discovers the failure.
|
||||
#[derive(Debug)]
|
||||
struct FailAfterFirstToken(Option<u64>);
|
||||
|
||||
impl TokenSource for FailAfterFirstToken {
|
||||
fn next_token(&mut self) -> Result<u64, ServerError> {
|
||||
self.0.take().ok_or_else(|| ServerError::RandomSource {
|
||||
reason: "test fault injection: exhausted after the first token".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST_TOKEN: u64 = 0xdead_beef_dead_beef;
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
fn loopback(port: u16) -> SocketAddr {
|
||||
SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port))
|
||||
}
|
||||
|
||||
fn requested_params() -> irtt_proto::Params {
|
||||
irtt_proto::Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 10_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
stamp_at: irtt_proto::StampAt::Both,
|
||||
clock: irtt_proto::Clock::Both,
|
||||
..irtt_proto::Params::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_open(client: &UdpSocket, addr: SocketAddr) {
|
||||
let request = irtt_proto::encode_request(
|
||||
irtt_proto::RequestToEncode::Open {
|
||||
params: &requested_params(),
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&request, addr).await.unwrap();
|
||||
}
|
||||
|
||||
/// Sends one open request and returns its reply, or `None` if the
|
||||
/// listener stayed silent within [`REPLY_TIMEOUT`].
|
||||
async fn open(client: &UdpSocket, addr: SocketAddr) -> Option<irtt_proto::OpenReply> {
|
||||
send_open(client, addr).await;
|
||||
let mut buffer = [0u8; 4096];
|
||||
match tokio::time::timeout(REPLY_TIMEOUT, client.recv_from(&mut buffer)).await {
|
||||
Ok(received) => {
|
||||
let (len, from) = received.unwrap();
|
||||
assert_eq!(from, addr, "a reply came from the wrong listener");
|
||||
Some(irtt_proto::decode_open_reply(&buffer[..len], None).unwrap())
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fatal failure inside one listener, after every listener in the set
|
||||
/// has already bound and answered real traffic, shuts its siblings down,
|
||||
/// joins every task and fails the set — with no external shutdown ever
|
||||
/// sent.
|
||||
///
|
||||
/// The failure is injected the same way the core's own tests already
|
||||
/// drive a token-allocation failure: a scripted
|
||||
/// [`crate::token::TokenSource`] substituted for one listener's
|
||||
/// [`crate::ServerCore`], through the test-only
|
||||
/// [`Server::from_socket_with_core`]. That source draws one token
|
||||
/// successfully, so the listener built from it answers a first open
|
||||
/// exactly like its healthy sibling does — which is the startup/readiness
|
||||
/// synchronization this test relies on: both are bound and actively
|
||||
/// serving real traffic before anything is asked to fail, not merely
|
||||
/// spawned. Only a *second* open against that listener draws its second
|
||||
/// token, which the scripted source refuses, and that single draw is the
|
||||
/// one injected fatal failure. Nothing about the socket, the receive loop
|
||||
/// or the sibling listener is touched.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_mid_run_listener_failure_shuts_down_its_siblings_and_fails_the_set() {
|
||||
let config = ServerConfig::default().with_min_send_interval(Duration::ZERO);
|
||||
|
||||
let failing_socket = UdpSocket::bind(loopback(0)).await.unwrap();
|
||||
let failing_core = ServerCore::with_token_source(
|
||||
config.clone(),
|
||||
Box::new(FailAfterFirstToken(Some(FIRST_TOKEN))),
|
||||
);
|
||||
let failing = Server::from_socket_with_core(failing_socket, failing_core).unwrap();
|
||||
let failing_addr = failing.local_addr().unwrap();
|
||||
|
||||
let healthy_socket = UdpSocket::bind(loopback(0)).await.unwrap();
|
||||
let healthy = Server::from_socket(healthy_socket, config).unwrap();
|
||||
let healthy_addr = healthy.local_addr().unwrap();
|
||||
|
||||
let set = ServerSet {
|
||||
servers: vec![failing, healthy],
|
||||
local_addrs: vec![failing_addr, healthy_addr],
|
||||
};
|
||||
|
||||
// A shutdown future that never resolves: whatever stops the set has
|
||||
// to come from the injected failure, not from this caller.
|
||||
let run = tokio::spawn(set.run(std::future::pending::<()>()));
|
||||
|
||||
let client = UdpSocket::bind(loopback(0)).await.unwrap();
|
||||
|
||||
// Both listeners answer a first, ordinary open before anything fails.
|
||||
let opened = open(&client, failing_addr)
|
||||
.await
|
||||
.expect("the failing listener is bound and serving before it fails");
|
||||
assert_eq!(opened.token, FIRST_TOKEN);
|
||||
assert!(
|
||||
open(&client, healthy_addr).await.is_some(),
|
||||
"the healthy listener is bound and serving too"
|
||||
);
|
||||
|
||||
// The second open at the failing listener draws its second token,
|
||||
// which the scripted source refuses. It terminates that listener
|
||||
// before any reply could be built, so this exchange places no
|
||||
// expectation on a reply.
|
||||
send_open(&client, failing_addr).await;
|
||||
|
||||
let outcome = tokio::time::timeout(TEST_TIMEOUT, run)
|
||||
.await
|
||||
.expect("the set stopped on its own, with no external shutdown sent")
|
||||
.expect("the supervising task did not panic");
|
||||
match outcome {
|
||||
Err(ServerSetError::ListenerRun { addr, source }) => {
|
||||
assert_eq!(
|
||||
addr, failing_addr,
|
||||
"the failure names the listener that actually failed"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
source,
|
||||
ServerRuntimeError::Core(ServerError::RandomSource { .. })
|
||||
),
|
||||
"unexpected failure source: {source}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected the injected listener failure, got {other:?}"),
|
||||
}
|
||||
|
||||
// The sibling did not linger after the failure: its task was joined
|
||||
// and its socket released, exactly like the listener that failed —
|
||||
// proof neither task nor socket was leaked.
|
||||
for addr in [healthy_addr, failing_addr] {
|
||||
std::net::UdpSocket::bind(addr)
|
||||
.unwrap_or_else(|error| panic!("{addr} was not released: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,11 @@
|
||||
//! instant, is served normally with no timestamp. Nothing about which datagrams
|
||||
//! reach [`ServerCore`](crate::ServerCore) depends on it.
|
||||
//!
|
||||
//! **Nothing consumes it yet.** It is captured here so a follow-up change can
|
||||
//! decide how a kernel-observed arrival time enters the protocol core; today
|
||||
//! the core still samples its own receive instant and no protocol timing is
|
||||
//! affected. It is a software wall-clock reading only — not a NIC hardware
|
||||
//! timestamp, not monotonic, and not a transmit timestamp.
|
||||
//! [`ServerCore`](crate::ServerCore) prefers it over its own receive sample
|
||||
//! when reporting a request's receive instant, falling back to that sample
|
||||
//! when no plausible kernel reading is available; see
|
||||
//! `preferred_receive_wall_ns`. It is a software wall-clock reading only —
|
||||
//! not a NIC hardware timestamp, not monotonic, and not a transmit timestamp.
|
||||
|
||||
use std::{io, net::SocketAddr, time::SystemTime};
|
||||
|
||||
|
||||
@@ -7,9 +7,15 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "statistics"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[dependencies]
|
||||
irtt-client.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "finite_vs_continuous"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
@@ -0,0 +1,38 @@
|
||||
# irtt-stats
|
||||
|
||||
Statistics aggregation over [`irtt-client`](https://crates.io/crates/irtt-client)
|
||||
events: loss/duplicate/late accounting, RTT and one-way-delay timing, and
|
||||
inter-packet delay variation (IPDV), as cumulative and rolling snapshots.
|
||||
|
||||
## Relationship to irtt-client
|
||||
|
||||
This crate does not open sessions or drive sockets. Feed it the `ClientEvent`
|
||||
stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed
|
||||
session via `StatsCollector::process`, and read back a `Snapshot`.
|
||||
|
||||
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
|
||||
scoped to a single target. A managed session covering multiple targets needs
|
||||
one collector per target — as the `irtt-rs` CLI does — since a target's
|
||||
sequence numbers start over at zero and feeding two targets into one
|
||||
collector would pair up unrelated probes.
|
||||
|
||||
## Retention modes
|
||||
|
||||
`StatsConfig::finite()` retains exact timing samples for its cumulative
|
||||
snapshot, so every metric with samples reports an exact median there —
|
||||
retention grows with the probe count. `StatsConfig::continuous()` keeps
|
||||
bounded running statistics instead (no exact median) plus a bounded
|
||||
4096-entry adjacent-sequence IPDV store per target, for long-running or
|
||||
unbounded sessions. Rolling-window snapshots are always running-only and
|
||||
report no medians, whichever `StatsConfig` produced them.
|
||||
|
||||
See `examples/` in the repository for a runnable comparison of both modes.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-stats](https://docs.rs/irtt-stats).
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Contrasts finite and continuous retention modes.
|
||||
//!
|
||||
//! Feeds the same synthetic stream of `irtt-client` events into two
|
||||
//! collectors — one configured with [`StatsConfig::finite`], one with
|
||||
//! [`StatsConfig::continuous`] — and prints how their cumulative snapshots
|
||||
//! differ. This uses synthetic events rather than a live session so the
|
||||
//! example runs deterministically with no network or server involved.
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-stats --example finite_vs_continuous
|
||||
//! ```
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use irtt_client::{ClientEvent, ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
|
||||
fn main() {
|
||||
let mut finite = StatsCollector::new(StatsConfig::finite());
|
||||
let mut continuous = StatsCollector::new(StatsConfig::continuous());
|
||||
|
||||
for event in synthetic_probe_stream(10) {
|
||||
finite.process(&event);
|
||||
continuous.process(&event);
|
||||
}
|
||||
|
||||
let finite_snapshot = finite.snapshot();
|
||||
let continuous_snapshot = continuous.snapshot();
|
||||
|
||||
println!(
|
||||
"finite: replies={}, rtt median_ns={:?}",
|
||||
finite_snapshot.events.echo_replies, finite_snapshot.rtt.primary.median_ns
|
||||
);
|
||||
println!(
|
||||
"continuous: replies={}, rtt median_ns={:?}",
|
||||
continuous_snapshot.events.echo_replies, continuous_snapshot.rtt.primary.median_ns
|
||||
);
|
||||
println!(
|
||||
"continuous mode keeps no exact samples, so its median is always None, \
|
||||
while finite mode's median is available once it has samples."
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a short synthetic stream of `EchoSent`/`EchoReply` pairs, plus one
|
||||
/// loss, standing in for what a real `irtt-client` session would emit.
|
||||
fn synthetic_probe_stream(count: u32) -> Vec<ClientEvent> {
|
||||
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
|
||||
let mut events = Vec::new();
|
||||
|
||||
for seq in 0..count {
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: Instant::now(),
|
||||
};
|
||||
events.push(ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote,
|
||||
scheduled_at: sent_at.mono,
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(50),
|
||||
timer_error: Duration::from_micros(20),
|
||||
});
|
||||
|
||||
if seq == count - 1 {
|
||||
// The last probe times out instead of receiving a reply.
|
||||
events.push(ClientEvent::EchoLoss {
|
||||
seq,
|
||||
sent_at,
|
||||
timeout_at: sent_at.mono + Duration::from_secs(1),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let rtt = Duration::from_millis(10 + u64::from(seq));
|
||||
let received_at = ClientTimestamp {
|
||||
wall: sent_at.wall + rtt,
|
||||
mono: sent_at.mono + rtt,
|
||||
};
|
||||
events.push(ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: rtt,
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_duration(rtt),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
@@ -235,4 +235,59 @@ mod tests {
|
||||
assert!(tracker.completed_pairs.len() <= limit, "seq {seq}");
|
||||
}
|
||||
}
|
||||
|
||||
mod properties {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// The hand-written `ipdv_tracker_bounded_mode_limits_sequence_state`
|
||||
/// test above only exercises a strictly increasing `0..512`
|
||||
/// sequence. Real traffic can duplicate, skip, or otherwise not
|
||||
/// strictly increase sequence numbers (see the client's
|
||||
/// `LateReply`/`DuplicateReply` handling), so this generates
|
||||
/// arbitrary, possibly-repeated, possibly-non-monotonic sequence
|
||||
/// streams and checks the same bounds still hold after every
|
||||
/// insert: `samples` and `sample_order` never exceed the
|
||||
/// configured limit and stay in lockstep, and `completed_pairs`
|
||||
/// never exceeds `samples.len()` (a successful entry can only
|
||||
/// persist once both its endpoint samples are present, see
|
||||
/// `try_pair`, so it is always a subset of the live sample keys).
|
||||
#[test]
|
||||
fn bounded_tracker_state_never_exceeds_the_limit(
|
||||
limit in 1usize..8,
|
||||
seqs in prop::collection::vec(0u32..40, 0..300),
|
||||
) {
|
||||
let mut tracker = IpdvTracker::new(Some(limit));
|
||||
for seq in seqs {
|
||||
tracker.insert(ipdv_sample(seq, i128::from(seq)));
|
||||
prop_assert!(tracker.samples.len() <= limit);
|
||||
prop_assert_eq!(tracker.sample_order.len(), tracker.samples.len());
|
||||
prop_assert!(tracker.completed_pairs.len() <= tracker.samples.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// A pair for a given adjacent `(seq - 1, seq)` boundary is only
|
||||
/// ever emitted once, however many times `insert` is called
|
||||
/// (including for already-seen or unrelated sequences in
|
||||
/// between). This is the unbounded case, so eviction cannot be
|
||||
/// the reason a pair fails to repeat.
|
||||
#[test]
|
||||
fn each_adjacent_pair_is_emitted_at_most_once(
|
||||
seqs in prop::collection::vec(0u32..12, 0..200),
|
||||
) {
|
||||
let mut tracker = IpdvTracker::new(None);
|
||||
let mut seen_pairs = std::collections::HashSet::new();
|
||||
for seq in seqs {
|
||||
for pair in tracker.insert(ipdv_sample(seq, i128::from(seq))) {
|
||||
let key = (pair.previous_seq, pair.current_seq);
|
||||
prop_assert!(
|
||||
seen_pairs.insert(key),
|
||||
"pair {key:?} emitted more than once"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
//! Statistics aggregation for `irtt-client` events.
|
||||
//!
|
||||
//! The crate consumes `irtt-client` events and produces cumulative or rolling
|
||||
//! snapshots for reporting and integration code.
|
||||
//! [`StatsCollector`] consumes [`irtt_client::ClientEvent`] values through
|
||||
//! [`StatsCollector::process`] and produces a cumulative [`Snapshot`], plus
|
||||
//! optional count- and time-based rolling snapshots, for reporting and
|
||||
//! integration code.
|
||||
//!
|
||||
//! # Retention modes
|
||||
//!
|
||||
//! [`StatsConfig`] selects between two retention modes via [`SampleMode`].
|
||||
//! [`StatsConfig::finite`] ([`SampleMode::Exact`]) retains an exact sample per
|
||||
//! timing metric, so every metric reports an exact median once it has
|
||||
//! samples; retention grows with the number of probes processed. This is the
|
||||
//! default and suits a run with a known, bounded duration.
|
||||
//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
|
||||
//! running statistics — no exact median — and bounds its adjacent-sequence
|
||||
//! IPDV tracking, so memory stays bounded for a long-running or unbounded
|
||||
//! session. Rolling snapshots always use running statistics regardless of
|
||||
//! this setting.
|
||||
//!
|
||||
//! See `examples/` in the repository for a runnable comparison of both modes.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Property tests for the count-based and time-based rolling windows.
|
||||
//!
|
||||
//! [`RollingEvents`] (in `src/rolling.rs`) keeps two independent `VecDeque`s:
|
||||
//! one bounded by [`StatsConfig::rolling_count`] and one bounded by
|
||||
//! [`StatsConfig::rolling_time`]. They are pushed to together but evicted
|
||||
//! independently, and each exposes its own snapshot
|
||||
//! ([`StatsCollector::rolling_count`] / [`StatsCollector::rolling_time`]).
|
||||
//! Reading `rolling.rs` and the existing `rolling_count_eviction_recomputes_*`
|
||||
//! / `rolling_time_eviction_uses_event_timestamps` tests in `tests/stats.rs`
|
||||
//! confirms there is no single window bounded by *both* limits at once: the
|
||||
//! two bounds do not compose as AND or OR on shared storage, they are simply
|
||||
//! two separate parallel windows over the same input stream. This file proves
|
||||
//! that independence generatively: with both bounds configured
|
||||
//! simultaneously, each window must behave exactly as if the *other* bound
|
||||
//! did not exist.
|
||||
//!
|
||||
//! The reference model is a plain `Vec<(ClientEvent, at_ms)>` history that is
|
||||
//! re-filtered from scratch after every operation (once by count, once by
|
||||
//! time), then replayed through a fresh [`StatsCollector`] to obtain a
|
||||
//! [`Snapshot`]. Because [`StatsCollector::rolling_count`] /
|
||||
//! [`StatsCollector::rolling_time`] are themselves computed by replaying the
|
||||
//! retained events through a fresh `CoreStats` on every call (see
|
||||
//! `rolling.rs`'s `snapshot_window`), and because both the production window
|
||||
//! and this reference model process the *same* `ClientEvent` values in the
|
||||
//! *same* order through the *same* pure, deterministic normalization and
|
||||
//! aggregation code, the two snapshots are expected to be bit-for-bit equal,
|
||||
//! not merely close. `Snapshot` derives `PartialEq`, so the comparison below
|
||||
//! is exact `assert_eq!` with no floating-point tolerance: there is no
|
||||
//! independent numerical computation on the reference side to accumulate
|
||||
//! error against, both sides run the identical arithmetic in the identical
|
||||
//! order.
|
||||
//!
|
||||
//! The reference model's time-window recomputation (filter the whole history
|
||||
//! by `at_ms >= latest_at_ms - window_ms`) is only equivalent to the
|
||||
//! production incremental sliding-eviction (which pops from the front using
|
||||
//! the cutoff computed at each push) when event timestamps are pushed in
|
||||
//! non-decreasing order, exactly as `normalization.rs` documents ("Rolling-
|
||||
//! window eviction assumes events are pushed in non-decreasing `at()`
|
||||
//! order"). The operation generator below enforces that by construction: a
|
||||
//! shared clock only ever moves forward, and every generated event borrows
|
||||
//! its `at()` timestamp from the clock's current value.
|
||||
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
||||
ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{LateReplyMode, SampleMode, Snapshot, StatsCollector, StatsConfig};
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// One generated operation. `Advance` moves the shared monotonic clock
|
||||
/// forward without producing an event; every other variant produces exactly
|
||||
/// one normalized event at the clock's current value.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Op {
|
||||
/// Advance the shared clock by this many milliseconds.
|
||||
Advance(u16),
|
||||
/// A probe send.
|
||||
Send,
|
||||
/// An on-time unique reply with the given client-observed RTT.
|
||||
Reply { raw_ms: u16 },
|
||||
/// A late reply matched to retained send state (measurable).
|
||||
LateReplyMatched { raw_ms: u16 },
|
||||
/// A late reply that could not be matched to retained state.
|
||||
LateReplyUnmatched,
|
||||
/// A duplicate reply for an already-completed sequence.
|
||||
Duplicate,
|
||||
/// A probe timeout / loss.
|
||||
Loss,
|
||||
/// A diagnostic warning event.
|
||||
Warning,
|
||||
}
|
||||
|
||||
fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
3 => (0u16..40).prop_map(Op::Advance),
|
||||
4 => Just(Op::Send),
|
||||
4 => (0u16..60).prop_map(|raw_ms| Op::Reply { raw_ms }),
|
||||
2 => (0u16..60).prop_map(|raw_ms| Op::LateReplyMatched { raw_ms }),
|
||||
1 => Just(Op::LateReplyUnmatched),
|
||||
1 => Just(Op::Duplicate),
|
||||
2 => Just(Op::Loss),
|
||||
1 => Just(Op::Warning),
|
||||
]
|
||||
}
|
||||
|
||||
const ADDR: &str = "127.0.0.1:2112";
|
||||
|
||||
/// Builds a `ClientTimestamp` purely by adding a millisecond offset to a
|
||||
/// fixed `base` `Instant` (and to `UNIX_EPOCH` for the wall-clock half). No
|
||||
/// operation below ever reads the real clock more than once (to establish
|
||||
/// `base`), so the resulting timestamps are fully deterministic functions of
|
||||
/// the generated `Op`s, with no wall-clock jitter to make a proptest shrink
|
||||
/// or CI run flaky.
|
||||
fn ts_at(base: Instant, ms: u64) -> ClientTimestamp {
|
||||
ClientTimestamp {
|
||||
mono: base + Duration::from_millis(ms),
|
||||
wall: UNIX_EPOCH + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn server_timing(seq: u32) -> ServerTiming {
|
||||
let base_ns = i64::from(seq) * 10_000_000;
|
||||
ServerTiming {
|
||||
receive_mono_ns: Some(base_ns + 1_000_000),
|
||||
send_mono_ns: Some(base_ns + 2_000_000),
|
||||
receive_wall_ns: Some(base_ns + 1_000_000),
|
||||
send_wall_ns: Some(base_ns + 2_000_000),
|
||||
midpoint_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
processing: Some(Duration::from_micros(100)),
|
||||
}
|
||||
}
|
||||
|
||||
fn one_way(seq: u32) -> OneWayDelaySample {
|
||||
OneWayDelaySample {
|
||||
client_to_server: Some(SignedDuration::from_nanos(1_000_000 + i128::from(seq))),
|
||||
server_to_client: Some(SignedDuration::from_nanos(2_000_000 + i128::from(seq))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `ClientEvent` for one non-`Advance` op. `clock_ms` is the
|
||||
/// shared clock's current value, used as the event's windowing timestamp
|
||||
/// (`at()`, see `normalization.rs`) for every variant.
|
||||
fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
|
||||
match *op {
|
||||
Op::Advance(_) => unreachable!("Advance does not produce an event"),
|
||||
Op::Send => ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote: ADDR.parse().unwrap(),
|
||||
scheduled_at: ts_at(base, clock_ms).mono,
|
||||
sent_at: ts_at(base, clock_ms),
|
||||
bytes: 32,
|
||||
send_call: Duration::from_micros(10),
|
||||
timer_error: Duration::from_micros(2),
|
||||
},
|
||||
Op::Reply { raw_ms } => {
|
||||
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
|
||||
let received_at = ts_at(base, clock_ms);
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: ADDR.parse().unwrap(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_millis(u64::from(raw_ms)),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_duration(Duration::from_millis(u64::from(
|
||||
raw_ms,
|
||||
))),
|
||||
},
|
||||
server_timing: Some(server_timing(seq)),
|
||||
one_way: Some(one_way(seq)),
|
||||
received_stats: Some(ReceivedStatsSample {
|
||||
count: Some(seq + 1),
|
||||
window: Some(0xff),
|
||||
}),
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
Op::LateReplyMatched { raw_ms } => {
|
||||
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
|
||||
let received_at = ts_at(base, clock_ms);
|
||||
ClientEvent::LateReply {
|
||||
seq,
|
||||
highest_seen: seq + 1,
|
||||
remote: ADDR.parse().unwrap(),
|
||||
sent_at: Some(sent_at),
|
||||
received_at,
|
||||
rtt: Some(RttSample {
|
||||
raw: Duration::from_millis(u64::from(raw_ms)),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_duration(Duration::from_millis(u64::from(
|
||||
raw_ms,
|
||||
))),
|
||||
}),
|
||||
server_timing: Some(server_timing(seq)),
|
||||
one_way: Some(one_way(seq)),
|
||||
received_stats: Some(ReceivedStatsSample {
|
||||
count: Some(seq + 1),
|
||||
window: Some(0xff),
|
||||
}),
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
Op::LateReplyUnmatched => ClientEvent::LateReply {
|
||||
seq,
|
||||
highest_seen: seq + 1,
|
||||
remote: ADDR.parse().unwrap(),
|
||||
sent_at: None,
|
||||
received_at: ts_at(base, clock_ms),
|
||||
rtt: None,
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
},
|
||||
Op::Duplicate => ClientEvent::DuplicateReply {
|
||||
seq,
|
||||
remote: ADDR.parse().unwrap(),
|
||||
received_at: ts_at(base, clock_ms),
|
||||
bytes: 64,
|
||||
},
|
||||
Op::Loss => ClientEvent::EchoLoss {
|
||||
seq,
|
||||
sent_at: ts_at(base, clock_ms),
|
||||
timeout_at: ts_at(base, clock_ms).mono,
|
||||
},
|
||||
Op::Warning => ClientEvent::Warning {
|
||||
kind: irtt_client::WarningKind::UntrackedReply,
|
||||
message: "generated".to_owned(),
|
||||
at: ts_at(base, clock_ms),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays `events` in order through a fresh collector using the rolling
|
||||
/// windows' own semantics (running statistics only, same late-reply policy),
|
||||
/// mirroring exactly what `rolling.rs`'s private `snapshot_window` does for
|
||||
/// the production windows. This is the "recompute from scratch" reference
|
||||
/// primitive both the count and time reference windows below are built on.
|
||||
fn replay(events: &[ClientEvent], late_replies: LateReplyMode) -> Snapshot {
|
||||
let mut collector = StatsCollector::new(StatsConfig {
|
||||
samples: SampleMode::RunningOnly,
|
||||
rolling_count: None,
|
||||
rolling_time: None,
|
||||
late_replies,
|
||||
});
|
||||
for event in events {
|
||||
collector.process(event);
|
||||
}
|
||||
collector.snapshot()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
|
||||
|
||||
/// With `rolling_count` and `rolling_time` configured simultaneously,
|
||||
/// each window must match a reference model that applies *only its own*
|
||||
/// bound to the full event history, re-filtered from scratch after every
|
||||
/// operation. Any accidental AND/OR coupling between the two bounds (for
|
||||
/// example the count window also dropping events outside the time
|
||||
/// window, or vice versa) would show up here as a mismatch.
|
||||
///
|
||||
/// The cumulative snapshot is checked too: it must equal a reference
|
||||
/// that replays the *entire* history, proving rolling eviction never
|
||||
/// reaches into the cumulative accounting.
|
||||
#[test]
|
||||
fn rolling_count_and_rolling_time_windows_are_independent(
|
||||
ops in prop::collection::vec(op_strategy(), 1..40),
|
||||
count_limit in 1usize..6,
|
||||
time_limit_ms in 1u64..80,
|
||||
count_only in prop::bool::ANY,
|
||||
) {
|
||||
let late_replies = if count_only {
|
||||
LateReplyMode::CountOnly
|
||||
} else {
|
||||
LateReplyMode::Measure
|
||||
};
|
||||
let config = StatsConfig {
|
||||
samples: SampleMode::RunningOnly,
|
||||
rolling_count: Some(count_limit),
|
||||
rolling_time: Some(Duration::from_millis(time_limit_ms)),
|
||||
late_replies,
|
||||
};
|
||||
let mut collector = StatsCollector::new(config);
|
||||
let base = Instant::now();
|
||||
let mut clock_ms: u64 = 0;
|
||||
// Full, unfiltered event history alongside each event's windowing
|
||||
// timestamp, so the reference windows below can be recomputed from
|
||||
// scratch after every step.
|
||||
let mut history: Vec<(ClientEvent, u64)> = Vec::new();
|
||||
|
||||
for (idx, op) in ops.iter().enumerate() {
|
||||
if let Op::Advance(delta) = op {
|
||||
clock_ms += u64::from(*delta);
|
||||
} else {
|
||||
let seq = u32::try_from(idx).unwrap();
|
||||
let event = build_event(op, seq, clock_ms, base);
|
||||
collector.process(&event);
|
||||
history.push((event, clock_ms));
|
||||
}
|
||||
|
||||
// Cumulative snapshot: unaffected by rolling eviction, so it must
|
||||
// equal a fresh replay of the entire history so far.
|
||||
let full: Vec<ClientEvent> = history.iter().map(|(event, _)| event.clone()).collect();
|
||||
prop_assert_eq!(
|
||||
collector.snapshot(),
|
||||
replay(&full, late_replies),
|
||||
"cumulative snapshot diverged from a full replay after op {}", idx
|
||||
);
|
||||
|
||||
// Count window reference: the last `count_limit` retained
|
||||
// events, ignoring the time bound entirely.
|
||||
let count_start = history.len().saturating_sub(count_limit);
|
||||
let count_ref: Vec<ClientEvent> = history[count_start..]
|
||||
.iter()
|
||||
.map(|(event, _)| event.clone())
|
||||
.collect();
|
||||
prop_assert_eq!(
|
||||
collector.rolling_count(),
|
||||
Some(replay(&count_ref, late_replies)),
|
||||
"count window diverged from the count-only reference after op {}", idx
|
||||
);
|
||||
|
||||
// Time window reference: every retained event whose timestamp is
|
||||
// within `time_limit_ms` of the *latest processed event's*
|
||||
// timestamp, ignoring the count bound entirely. This recompute-
|
||||
// from-scratch filter is equivalent to the production window's
|
||||
// incremental sliding-eviction only because `clock_ms` is
|
||||
// non-decreasing across pushes (enforced by construction above:
|
||||
// `Advance` never subtracts).
|
||||
if let Some((_, latest_at_ms)) = history.last() {
|
||||
let cutoff = latest_at_ms.checked_sub(time_limit_ms);
|
||||
let time_ref: Vec<ClientEvent> = history
|
||||
.iter()
|
||||
.filter(|(_, at_ms)| cutoff.is_none_or(|cutoff| *at_ms >= cutoff))
|
||||
.map(|(event, _)| event.clone())
|
||||
.collect();
|
||||
prop_assert_eq!(
|
||||
collector.rolling_time(),
|
||||
Some(replay(&time_ref, late_replies)),
|
||||
"time window diverged from the time-only reference after op {}", idx
|
||||
);
|
||||
} else {
|
||||
prop_assert_eq!(
|
||||
collector.rolling_time(),
|
||||
Some(replay(&[], late_replies)),
|
||||
"time window should be empty before any event after op {}", idx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,7 +11,7 @@ ci = "github"
|
||||
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"]
|
||||
# Scope dist's tag matching to irtt-cli releases only: irtt-proto/irtt-client/
|
||||
# Scope dist's tag matching to irtt-rs releases only: irtt-proto/irtt-client/
|
||||
# irtt-server/irtt-stats are libraries published to crates.io and tagged for
|
||||
# provenance, but must not trigger cargo-dist or produce a GitHub Release.
|
||||
tag-namespace = "irtt-cli"
|
||||
tag-namespace = "irtt-rs"
|
||||
@@ -13,7 +13,7 @@ upstream executable.
|
||||
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
|
||||
|
||||
```sh
|
||||
cargo build -p irtt-cli
|
||||
cargo build -p irtt-rs --bin irtt-client
|
||||
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
|
||||
```
|
||||
|
||||
@@ -118,13 +118,13 @@ python3 scripts/run_netem_interop_ci.py \
|
||||
On Linux:
|
||||
|
||||
```sh
|
||||
cargo build -p irtt-cli
|
||||
cargo build -p irtt-rs --bin irtt-client
|
||||
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"
|
||||
--irtt-rs-command "$PWD/target/debug/irtt-client"
|
||||
```
|
||||
|
||||
Run one scenario:
|
||||
@@ -139,5 +139,5 @@ scenario contains the existing comparison harness output under
|
||||
`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
|
||||
On macOS, `ip netns` and `tc netem` are unavailable; use the Gitea Actions
|
||||
workflow for netem coverage.
|
||||
@@ -0,0 +1,302 @@
|
||||
# irtt-client
|
||||
|
||||
## NAME
|
||||
|
||||
irtt-client - IRTT-compatible stream client
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
|
||||
|
||||
`irtt-client` `--list-columns`
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`irtt-client` probes one or more IRTT-compatible servers over UDP and prints
|
||||
per-probe measurement events as they arrive, plus a final summary for
|
||||
eligible runs.
|
||||
|
||||
## TARGETS
|
||||
|
||||
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
|
||||
`LABEL=` prefix assigns the logical target name used in output; without it,
|
||||
the target string itself is used as the label. Labeled and unlabeled targets
|
||||
can be freely mixed in one argument list. Prefer explicit labels for
|
||||
long-running dynamic target sets: the label is the stable target identity
|
||||
across replacements.
|
||||
|
||||
```sh
|
||||
irtt-client host-a:2112
|
||||
irtt-client eu=host-a:2112 us=host-b:2112
|
||||
irtt-client host-a:2112 eu=host-b:2112
|
||||
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
|
||||
```
|
||||
|
||||
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
|
||||
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
|
||||
target. The modifier uses `@`, not a shell command separator; quote an argument
|
||||
if its key needs shell quoting.
|
||||
|
||||
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
||||
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||
label or address when it would otherwise begin `@hmac=`; escaping the first
|
||||
`=` is how an unlabeled target or a label can contain that character. An `=` in
|
||||
an HMAC value, including Base64 padding, does not need escaping. Positional
|
||||
target arguments may contain a raw comma; stdin target sets have the additional
|
||||
`\,` escape described below.
|
||||
|
||||
Shells process unquoted backslashes and characters such as semicolons before
|
||||
the client sees them. Single-quote a target when it contains parser escapes,
|
||||
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
|
||||
backslashes reach `irtt-client` unchanged.
|
||||
|
||||
Multiple targets are probed concurrently. By default, a multi-target session
|
||||
uses **staggered** pacing, spacing active targets across the probe interval;
|
||||
pass `--pacing burst` to send one probe to every active target back-to-back
|
||||
once per interval instead. Final per-target summaries print in the order
|
||||
targets were supplied on the command line, not the order in which they
|
||||
finish or their labels sort alphabetically.
|
||||
|
||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||
|
||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||
Positional targets remain an initial desired set, and may be omitted to
|
||||
start empty:
|
||||
|
||||
~~~sh
|
||||
irtt-client --duration 0 --targets-stdin
|
||||
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
|
||||
~~~
|
||||
|
||||
Each non-empty stdin record declares the complete desired target set, rather
|
||||
than a delta. A later record replaces an earlier set: targets absent from it
|
||||
are retired, unchanged target configurations retain their generation, and a
|
||||
changed address or HMAC setting creates a fresh generation. Under transient
|
||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||
|
||||
Commas frame targets in stdin records; escape a literal comma within one
|
||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
|
||||
client is being controlled. For example, in one terminal:
|
||||
|
||||
~~~sh
|
||||
mkfifo /tmp/irtt-targets
|
||||
irtt-client --duration 0 --targets-stdin --hmac default-secret \
|
||||
< /tmp/irtt-targets
|
||||
~~~
|
||||
|
||||
Then open the FIFO once from another terminal and keep that descriptor open
|
||||
while publishing complete desired sets:
|
||||
|
||||
~~~sh
|
||||
exec 3> /tmp/irtt-targets
|
||||
printf '%s\n' 'ams=ams.example:2112,sg=sg.example:2112@hmac=sg-secret' >&3
|
||||
printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
|
||||
# Publish more sets as needed, then send EOF and stop the client:
|
||||
exec 3>&-
|
||||
~~~
|
||||
|
||||
The exact record `[]` selects an empty desired set. Empty records are
|
||||
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
|
||||
or closure of the control plane and requests a graceful stop. A desired-state
|
||||
revision that was read but has not yet been acknowledged as applied may be
|
||||
discarded when EOF is observed; EOF does not wait for a capacity-rejected
|
||||
revision to become applicable.
|
||||
|
||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||
desired set to 128 targets and retains at most 256 live target generations
|
||||
while replacements drain.
|
||||
|
||||
## FINITE VERSUS CONTINUOUS OPERATION
|
||||
|
||||
`--duration` controls the run length:
|
||||
|
||||
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
|
||||
after that long, and a final summary is always printed for table output
|
||||
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
|
||||
- `--duration 0` is **continuous**: the client runs until interrupted
|
||||
(`Ctrl-C`). For table output, a final summary is printed only if the run
|
||||
was interrupted. Stdin-controlled mode is the exception: EOF also requests
|
||||
a graceful stop and prints the final retained summary. An uninterrupted
|
||||
continuous run that exits for another reason does not get one.
|
||||
|
||||
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
||||
distinction also determines what statistics can be computed and how much
|
||||
memory a long run retains.
|
||||
|
||||
## OUTPUT
|
||||
|
||||
### `--format FORMAT`
|
||||
|
||||
One of four event-row formats, default `table`:
|
||||
|
||||
- `table`: human-readable terminal output; the only format that prints a
|
||||
final summary.
|
||||
- `csv`: comma-separated output.
|
||||
- `tsv`: tab-separated output.
|
||||
- `jsonl`: one JSON object per event.
|
||||
|
||||
CSV, TSV, and JSON Lines default to **all** columns for structured export;
|
||||
`table` defaults to a compact column set and hides `echo_sent` rows. An
|
||||
explicit `--columns` selection other than `default` shows all event rows,
|
||||
including `echo_sent`; `--columns default` is equivalent to omitting the
|
||||
option and keeps `echo_sent` hidden for table output.
|
||||
|
||||
### `--columns COLUMNS` / `-c COLUMNS`
|
||||
|
||||
Comma-separated event row columns, or the special values `default` (the
|
||||
format's default set) or `all` (every column). List every available column,
|
||||
its meaning, and its aliases with:
|
||||
|
||||
```sh
|
||||
irtt-client --list-columns
|
||||
```
|
||||
|
||||
(`--list-columns` does not require a target.)
|
||||
|
||||
### `--header auto|always|never`
|
||||
|
||||
Header policy for `table`, `csv`, and `tsv` output (default `auto`). JSON
|
||||
Lines never prints a header line.
|
||||
|
||||
### `--verbose`
|
||||
|
||||
Includes extra fields in table output and final summaries. Has no effect on
|
||||
CSV, TSV, or JSON Lines output either way — those formats show their default
|
||||
all-column set or an explicit `--columns` selection exactly as given,
|
||||
regardless of `--verbose`.
|
||||
|
||||
### Examples
|
||||
|
||||
```sh
|
||||
irtt-client <server> --format table
|
||||
irtt-client <server> --format jsonl
|
||||
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us
|
||||
```
|
||||
|
||||
A stream containing only effective RTT values in microseconds:
|
||||
|
||||
```sh
|
||||
irtt-client <server> --format tsv --columns effective_rtt_us --header never
|
||||
```
|
||||
|
||||
## MEASUREMENT FIELDS
|
||||
|
||||
Useful measurement columns:
|
||||
|
||||
- `raw_rtt_us`: client-observed send-to-receive RTT. Always userspace-timed
|
||||
and unaffected by kernel timestamp preference (below).
|
||||
- `adjusted_rtt_us`: RTT adjusted for server processing when available. Can
|
||||
be negative when server processing exceeds the measured raw RTT.
|
||||
- `effective_rtt_us`: `adjusted_rtt_us` when available, otherwise
|
||||
`raw_rtt_us`.
|
||||
- `rd_us` / `sd_us` (selectable with the longer aliases `receive_delay_us` /
|
||||
`send_delay_us`, but always emitted under the short names): one-way delay
|
||||
estimates. Can be negative because of clock skew between client and
|
||||
server.
|
||||
- `ipdv_us`: inter-packet delay variation.
|
||||
- `server_processing_us`: time spent processing the packet at the server.
|
||||
- `kernel_rx_ns`: kernel receive timestamp, in Unix nanoseconds, when
|
||||
captured (see below).
|
||||
|
||||
### Kernel timestamps (Linux)
|
||||
|
||||
On Linux, one-way delay estimates prefer a kernel-captured timestamp over
|
||||
userspace timing for this client's own send/receive endpoint, where the
|
||||
platform and socket support it, reducing the effect of scheduling and
|
||||
queuing delay. The peer's reported endpoint gets the same preference when it
|
||||
is the `irtt-server` applet running on Linux; against another
|
||||
IRTT-compatible server, whatever wall-clock value it reports is used as-is.
|
||||
This is best-effort: an unsupported or implausible kernel timestamp falls
|
||||
back to userspace timing. `raw_rtt_us` is unaffected either way;
|
||||
`adjusted_rtt_us`/`effective_rtt_us` can still shift slightly, since server
|
||||
processing time is derived from the same server receive endpoint.
|
||||
|
||||
## MEASUREMENTS AND MEMORY
|
||||
|
||||
Finite and continuous modes make a different tradeoff between exact
|
||||
statistics and bounded memory.
|
||||
|
||||
**Finite mode** retains exact timing samples (and adjacent-sequence IPDV
|
||||
state) so the final summary can report exact medians. Retained memory
|
||||
therefore grows with the number of probes sent, at a small constant cost per
|
||||
probe (on the order of a few hundred bytes of retained sample state per
|
||||
probe per target, before allocator overhead) — a very long finite run at a
|
||||
short interval is the case to plan capacity for. The client warns on stderr
|
||||
when its own estimate of this retained state crosses roughly 128 MiB, and
|
||||
again more strongly past roughly 512 MiB and 1 GiB; treat those numbers as
|
||||
practical guidance, not a promised RSS ceiling, since allocator and OS
|
||||
overhead sit outside the estimate.
|
||||
|
||||
**Continuous mode** (`--duration 0`) does not retain a growing sample
|
||||
history: running statistics keep count/min/max/mean/variance only, so exact
|
||||
medians are unavailable. It still keeps a bounded adjacent-sequence IPDV
|
||||
store, capped at 4,096 sequences per target, so nearby replies can still
|
||||
form IPDV pairs. Retained statistics state therefore approaches a fixed
|
||||
per-target bound rather than growing with elapsed run time.
|
||||
|
||||
Reply classification (independent of the statistics choice above) is also
|
||||
bounded per target: each of the pending, timed-out, and completed/duplicate
|
||||
sequence stores has its own 4,096-entry limit — not one shared total across
|
||||
the three. A full pending store makes the client fail and drain that target
|
||||
as resource-exhausted rather than wait for capacity to free. Timed-out and
|
||||
completed state evict their oldest entries, so a sufficiently old late reply
|
||||
can still be seen and counted, but no longer has retained send state for
|
||||
measurements such as RTT.
|
||||
|
||||
Overall, retained state scales with the **number of configured targets**
|
||||
and these fixed per-target limits, not with total probes sent in continuous
|
||||
mode. Output is written as events arrive rather than accumulated; the
|
||||
managed event stream feeding it is itself bounded and lossy (16,384 events),
|
||||
so a sufficiently slow consumer can miss events, in which case the client
|
||||
reports that resulting output/statistics may be incomplete.
|
||||
|
||||
For capacity planning on constrained devices (for example an OpenWrt
|
||||
router): a finite run's statistics memory is roughly proportional to
|
||||
`targets × probes_sent`, while a continuous run's statistics memory
|
||||
approaches a fixed multiple of `targets` regardless of how long it runs.
|
||||
|
||||
## PEER CLOSE AND EXIT BEHAVIOR
|
||||
|
||||
A peer closing a finite run is accepted as a terminal, successful outcome.
|
||||
In continuous mode, an uninterrupted peer closure is treated as an error —
|
||||
the client exits nonzero — so a process supervisor can restart it; an
|
||||
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
||||
mode it was in.
|
||||
|
||||
With `--targets-stdin`, peer closure is target-local: other desired targets
|
||||
and the stdin controller continue running. Including that target in a later
|
||||
complete set starts a fresh generation.
|
||||
|
||||
## EXIT STATUS
|
||||
|
||||
`0` on success (including a finite run's ordinary completion, and an
|
||||
interrupted run of either mode). Nonzero if the managed driver failed, if an
|
||||
uninterrupted continuous run ended by peer closure, or if the run was not
|
||||
interrupted and no target completed successfully. An interrupted run never
|
||||
fails on the last of these conditions — for example, interrupting a
|
||||
continuous run whose only target never opened still exits `0`.
|
||||
|
||||
## SERVER FILL REQUEST
|
||||
|
||||
`--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
|
||||
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
|
||||
hexadecimal pattern). See `irtt-server(1)` SERVER FILL for what the server
|
||||
does with an unset or unparseable request.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
```sh
|
||||
irtt-client netperf-eu.bufferbloat.net:2112
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
||||
irtt-client host-a:2112 host-b:2112 --pacing burst
|
||||
```
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
`irtt-tui(1)`, `irtt-server(1)`, `irtt-rs(1)`, and `irtt-client --help` for
|
||||
the full option list, including HMAC, clock/timestamp negotiation, DSCP, and
|
||||
TTL flags.
|
||||
@@ -0,0 +1,64 @@
|
||||
# irtt-rs
|
||||
|
||||
## NAME
|
||||
|
||||
irtt-rs - IRTT-compatible multi-applet dispatcher
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
|
||||
|
||||
`irtt-rs` `tui` [*OPTIONS*] *[LABEL=]TARGET*...
|
||||
|
||||
`irtt-rs` `server` [*OPTIONS*]
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`irtt-rs` is a multicall dispatcher: one binary that runs the client, TUI,
|
||||
or server applet depending on the subcommand given, or on the name it was
|
||||
invoked/copied as. It exists for router-friendly single-binary deployment;
|
||||
it does not add behavior beyond what `irtt-client(1)`, `irtt-tui(1)`, and
|
||||
`irtt-server(1)` already provide.
|
||||
|
||||
Each subcommand behaves exactly like its dedicated binary — `irtt-rs client
|
||||
...` is equivalent to running `irtt-client ...` directly. See those manuals
|
||||
for target syntax, options, output, and exit behavior.
|
||||
|
||||
## APPLET BINARY NAMES
|
||||
|
||||
The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
|
||||
`irtt-server`. A dedicated binary always runs its own role, regardless of
|
||||
what it is invoked or copied as — it does not consult argv0 dispatch logic.
|
||||
|
||||
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
|
||||
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
|
||||
`irtt-server` — it runs that applet directly, without needing a subcommand.
|
||||
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
|
||||
rejected as an unknown applet name before subcommand parsing is even
|
||||
attempted — `irtt-custom server` is a hard error, not a fallthrough. Only
|
||||
`irtt-rs` itself, or a name that doesn't start with `irtt-` at all, falls
|
||||
through to ordinary `client`/`tui`/`server` subcommand parsing.
|
||||
|
||||
## FEATURE-DEPENDENT APPLET AVAILABILITY
|
||||
|
||||
Each applet is gated by a Cargo feature on the `irtt-rs` package: `client`,
|
||||
`tui`, `server`. All three are in the default feature set. A build with a
|
||||
feature disabled still produces the `irtt-rs` binary, but that applet is
|
||||
unavailable at runtime — running it (by subcommand or by binary name)
|
||||
reports which feature to rebuild with, and `irtt-rs --help` lists which
|
||||
applets the current build actually contains.
|
||||
|
||||
```sh
|
||||
cargo install irtt-rs --locked --no-default-features --features client
|
||||
```
|
||||
|
||||
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
|
||||
|
||||
`irtt-rs` is packaging and dispatch only. All probing, display, and server
|
||||
behavior lives in `irtt-client(1)`, `irtt-tui(1)`, and `irtt-server(1)` —
|
||||
consult those manuals for anything observable about running a probe or
|
||||
serving one.
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
|
||||
@@ -0,0 +1,261 @@
|
||||
# irtt-server
|
||||
|
||||
## NAME
|
||||
|
||||
irtt-server - IRTT-compatible UDP server
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
|
||||
|
||||
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`irtt-server` answers IRTT echo requests over UDP. It is thin orchestration
|
||||
over the reusable `irtt-server` library: one current-thread Tokio runtime,
|
||||
one or more listeners, and the library's own session/policy logic.
|
||||
|
||||
Run with no arguments for the ordinary case:
|
||||
|
||||
```sh
|
||||
irtt-server
|
||||
```
|
||||
|
||||
With no `--bind`, the server listens on the standard IRTT port (2112) on the
|
||||
wildcard address for both address families, in this fixed order:
|
||||
|
||||
1. `[::]:2112`
|
||||
2. `0.0.0.0:2112`
|
||||
|
||||
This is available wherever the platform supports safe wildcard reply-source
|
||||
selection (see [Wildcard binds](#wildcard-binds) below). Any explicit
|
||||
`--bind` **replaces** this default pair entirely rather than adding to it —
|
||||
`irtt-server --bind 127.0.0.1:2112` binds only that one address.
|
||||
|
||||
If one of the two default addresses cannot be bound because its address
|
||||
family itself has no local support at all — IPv6 disabled on an otherwise
|
||||
IPv4-capable host, for example — the server falls back to serving just the
|
||||
other one instead of failing outright, and says so on startup. Any other
|
||||
reason a default listener fails — the port already in use, permission
|
||||
denied, no safe wildcard reply-source path — is not a family-support
|
||||
problem and is not silently worked around; the invocation fails as it would
|
||||
for an explicit `--bind`. This fallback applies only to the implicit default
|
||||
pair: an explicit `--bind` list always keeps the ordinary all-or-nothing
|
||||
behavior described below.
|
||||
|
||||
## OPTIONS
|
||||
|
||||
### `--bind ADDR`
|
||||
|
||||
Local UDP address to bind, as `ADDR:PORT`, for example `127.0.0.1:2112` or
|
||||
`[::1]:2112`. Host names are not resolved. Repeatable.
|
||||
|
||||
Repeat the option to serve several addresses from one process, in the order
|
||||
given. Every listener applies the same policy options, but each keeps its
|
||||
own sessions and tokens, so a session belongs to the address it was opened
|
||||
on. Binding is all-or-nothing: if any address cannot be bound, none are
|
||||
served.
|
||||
|
||||
A port of `0` selects an unused port per listener, so two such binds get two
|
||||
different ports.
|
||||
|
||||
With no `--bind` at all, see [Default bind](#default-bind) above.
|
||||
|
||||
### `--hmac KEY`
|
||||
|
||||
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
|
||||
every request must carry a valid MAC and every reply is authenticated;
|
||||
without one, authenticated requests are dropped. The key is visible in the
|
||||
process arguments.
|
||||
|
||||
### `--max-sessions COUNT`
|
||||
|
||||
Maximum number of simultaneously live sessions **per listener**, not per
|
||||
process. Once a listener's table is full, a session-creating open to it is
|
||||
dropped silently; nothing is evicted. Zero refuses every session-creating
|
||||
open.
|
||||
|
||||
### `--max-packet-length BYTES`
|
||||
|
||||
Maximum echo datagram size a session may negotiate, in bytes. A longer
|
||||
requested length is reduced during negotiation, an open whose mandatory
|
||||
field block would not fit is refused, and inbound echo requests are admitted
|
||||
against the same limit. This is a resource bound, not an MTU.
|
||||
|
||||
### `--min-interval DURATION`
|
||||
|
||||
Floor on the send interval a session may negotiate. The negotiated interval
|
||||
is still capped at a quarter of the idle timeout afterwards, so a
|
||||
`--min-interval` above that cap is not the value actually negotiated.
|
||||
|
||||
A session's reply allowance refills at the shorter of this configured floor
|
||||
and the interval it actually negotiated. Ordinarily that is the configured
|
||||
floor itself, since the negotiated interval is normally at least as long —
|
||||
a 10ms `--min-interval` against a 1s negotiated interval refills every
|
||||
10ms. The two differ only when the idle-timeout cap above pulled the
|
||||
negotiated interval below the configured floor, in which case the shorter,
|
||||
negotiated value is what the allowance actually refills at: a server must
|
||||
not enforce a cadence it did not advertise to the client. Use `0` for no
|
||||
time-based throttling.
|
||||
|
||||
### `--burst COUNT`
|
||||
|
||||
Echo replies one session may have answered before its allowance has to
|
||||
refill. Use `0` for no allowance at all, which rate-limits every echo
|
||||
request whatever the interval is.
|
||||
|
||||
### `--idle-timeout DURATION`
|
||||
|
||||
Release a session after this long without a served or rate-limited echo
|
||||
request. The deadline runs from the open, and release is silent. Use `0` to
|
||||
expire a session at the next evaluation; it is not a way to disable expiry.
|
||||
|
||||
### `--max-duration DURATION`
|
||||
|
||||
Maximum test duration a session may negotiate. A longer request is reduced
|
||||
to it, and a continuous request is answered with it. Omit this flag for no
|
||||
maximum; a maximum of zero cannot be expressed, because a negotiated
|
||||
duration of zero means continuous.
|
||||
|
||||
### `--timestamp-allowance MODE`
|
||||
|
||||
How many timestamps this server will provide: `dual` (default, honors a
|
||||
request for send, receive, both, or midpoint timestamps), `single`
|
||||
(provides at most one timestamp instant; a request for both is negotiated to
|
||||
midpoint), or `none` (no timestamps at all). The requested clock source is
|
||||
never changed, only which instants are reported, so a single instant is
|
||||
still reported once per requested clock.
|
||||
|
||||
### `--no-dscp`
|
||||
|
||||
Refuse to provide requested traffic-class marking. Any requested DSCP is
|
||||
negotiated to zero, so the client is told its echo replies will be unmarked,
|
||||
and they are sent unmarked. The session is not refused.
|
||||
|
||||
Durations everywhere in this command accept an integer followed by a unit:
|
||||
`ms`, `s`, or `m`.
|
||||
|
||||
## OUTPUT
|
||||
|
||||
Each bound endpoint is printed on startup, once every listener is up, which
|
||||
also resolves a port of `0`:
|
||||
|
||||
```
|
||||
irtt-server: listening on [::]:2112
|
||||
irtt-server: listening on 0.0.0.0:2112
|
||||
```
|
||||
|
||||
`Ctrl-C` stops the server gracefully.
|
||||
|
||||
## MULTIPLE LISTENERS
|
||||
|
||||
Every invocation runs through the same multi-listener path, so one address —
|
||||
whether from the default pair or a single explicit `--bind` — is an ordinary
|
||||
set of one rather than a separate mode. The policy options above apply to
|
||||
every listener, but listeners are otherwise independent: each has its own
|
||||
sessions and tokens, and `--max-sessions` bounds each listener rather than
|
||||
the process. If any listener fails while running, the others are shut down
|
||||
with it rather than leaving a service configured for two families answering
|
||||
on one.
|
||||
|
||||
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
|
||||
does.
|
||||
|
||||
## SERVER FILL
|
||||
|
||||
Echo replies fill their payload according to the negotiated `ServerFill`
|
||||
mode, which a client requests with `irtt-client`'s `--sfill` (see
|
||||
`irtt-client(1)`):
|
||||
|
||||
- `none`: zero-filled.
|
||||
- `rand`: random bytes.
|
||||
- `pattern:HEX`: the given repeating hexadecimal pattern.
|
||||
|
||||
Every valid descriptor is honored. A descriptor the server cannot parse, and
|
||||
a client that expresses no fill preference at all, both get the default
|
||||
`pattern:69727474`, i.e. the repeating bytes `69 72 74 74` (`irtt`).
|
||||
|
||||
## WILDCARD BINDS
|
||||
|
||||
An explicit interface address works on every supported system. A wildcard
|
||||
bind such as `0.0.0.0:2112` or `[::]:2112` reads each request's local
|
||||
destination from packet metadata and sends that request's reply from the
|
||||
same address, so a client on a multi-homed host is answered from the
|
||||
endpoint it actually contacted.
|
||||
|
||||
That reply-source selection is implemented on **Linux, macOS, and FreeBSD**.
|
||||
Elsewhere, a wildcard bind is refused at construction rather than served
|
||||
from a routing-table-selected source address a client would discard — this
|
||||
also applies to the zero-argument default, which will fail with a clear
|
||||
error asking for an explicit `--bind` on such a platform. This correctness
|
||||
rule is deliberate and is not weakened by the zero-argument default.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Run with the default wildcard listeners on the standard port:
|
||||
|
||||
```sh
|
||||
irtt-server
|
||||
```
|
||||
|
||||
Bind a single explicit address:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 127.0.0.1:2112
|
||||
```
|
||||
|
||||
Through the `irtt-rs` dispatcher:
|
||||
|
||||
```sh
|
||||
irtt-rs server --bind 192.0.2.10:2112
|
||||
```
|
||||
|
||||
Serve two explicit addresses from one process:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 0.0.0.0:2112 \
|
||||
--bind [::]:2112
|
||||
```
|
||||
|
||||
Apply session policy:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 192.0.2.10:2112 \
|
||||
--max-sessions 512 \
|
||||
--idle-timeout 30s
|
||||
```
|
||||
|
||||
Restrict optional capabilities:
|
||||
|
||||
```sh
|
||||
irtt-server \
|
||||
--bind 192.0.2.10:2112 \
|
||||
--timestamp-allowance single \
|
||||
--no-dscp
|
||||
```
|
||||
|
||||
Require authentication:
|
||||
|
||||
```sh
|
||||
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
||||
```
|
||||
|
||||
## EXIT STATUS
|
||||
|
||||
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
|
||||
fails while running.
|
||||
|
||||
## LIMITATIONS
|
||||
|
||||
- No hostname resolution, interface expansion, daemonization, or
|
||||
configuration file — binds are explicit addresses only.
|
||||
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
|
||||
FreeBSD; such platforms must bind explicit addresses.
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
|
||||
the full option list.
|
||||
@@ -0,0 +1,83 @@
|
||||
# irtt-tui
|
||||
|
||||
## NAME
|
||||
|
||||
irtt-tui - IRTT-compatible terminal UI client
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`irtt-tui` is a live dashboard over the same probing engine as
|
||||
`irtt-client(1)`: a graph and status view instead of a printed event stream.
|
||||
At least one target is required.
|
||||
|
||||
Target syntax, multiple targets, and pacing are identical to
|
||||
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
|
||||
`--pacing staggered|burst` option. The full set of negotiation flags
|
||||
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
|
||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
||||
well; consult that manual for their meaning.
|
||||
|
||||
## CONTINUOUS DEFAULT
|
||||
|
||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
||||
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
|
||||
a finite run:
|
||||
|
||||
```sh
|
||||
irtt-tui <server> --duration 30s
|
||||
```
|
||||
|
||||
## CONTROLS
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `q`, `Ctrl-C` | Quit |
|
||||
| `p` | Toggle display pause (probing continues; only redraws stop) |
|
||||
| `g` | Toggle Graph / Dashboard view |
|
||||
| `m` | Cycle graph metric |
|
||||
| `r` | Clear visible graph history |
|
||||
| `←` / `→` | Pan graph |
|
||||
| `PageUp` / `PageDown` | Page-pan graph |
|
||||
| `Home` / `End` | Jump to oldest / jump to live |
|
||||
| `+` / `=` | Zoom graph in |
|
||||
| `-` | Zoom graph out |
|
||||
| `0` | Reset graph window and zoom |
|
||||
|
||||
## RETAINED HISTORY
|
||||
|
||||
The TUI keeps its own bounded presentation state on top of the statistics
|
||||
retention described in `irtt-client(1)`:
|
||||
|
||||
- Up to 100,000 graph samples per target.
|
||||
- Up to 80 recent status/log messages.
|
||||
|
||||
`r` clears the currently visible graph history and returns the viewport to
|
||||
live; it does not change the 100,000-sample cap itself. These are
|
||||
presentation bounds on top of the client's own statistics retention (see
|
||||
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
|
||||
total memory is dominated by whichever of the two is larger for your target
|
||||
count.
|
||||
|
||||
## EXIT BEHAVIOR
|
||||
|
||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
||||
exit status follows the same peer-close and driver-failure rules as
|
||||
`irtt-client(1)`.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
```sh
|
||||
irtt-tui host.example
|
||||
irtt-tui eu=host.example
|
||||
irtt-tui eu=host-a.example us=host-b.example --pacing burst
|
||||
irtt-tui host.example --duration 30s
|
||||
```
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
|
||||
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.
|
||||
@@ -0,0 +1,10 @@
|
||||
target
|
||||
artifacts
|
||||
coverage
|
||||
|
||||
# The committed seed corpus under corpus/ is small and curated by hand; a
|
||||
# local `cargo fuzz run` grows each target's corpus directory far beyond
|
||||
# that, so ignore anything not already tracked instead of the directory
|
||||
# itself.
|
||||
corpus/*/*
|
||||
!corpus/*/
|
||||
Generated
+246
@@ -0,0 +1,246 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-proto"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"md-5",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-proto-fuzz"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"libfuzzer-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "irtt-proto-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4.13"
|
||||
|
||||
[dependencies.irtt-proto]
|
||||
path = "../crates/irtt-proto"
|
||||
|
||||
# Use independent workspace for fuzzers
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[[bin]]
|
||||
name = "decode_request"
|
||||
path = "fuzz_targets/decode_request.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_open_reply"
|
||||
path = "fuzz_targets/decode_open_reply.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_echo_reply"
|
||||
path = "fuzz_targets/decode_echo_reply.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_params"
|
||||
path = "fuzz_targets/decode_params.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_varint"
|
||||
path = "fuzz_targets/decode_varint.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,77 @@
|
||||
# `irtt-proto` fuzzing
|
||||
|
||||
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
|
||||
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
|
||||
|
||||
This project is intentionally isolated from the normal stable/MSRV
|
||||
workspace: it lives in its own `[workspace]` under `fuzz/`, is never a member
|
||||
of the root workspace, requires nightly Rust, and is not built by
|
||||
`cargo check`/`test`/`clippy`/`doc` at the workspace root.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- nightly Rust (`rustup toolchain install nightly`)
|
||||
- `cargo-fuzz` (`cargo install cargo-fuzz --locked --version 0.13.1`)
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Exercises |
|
||||
| ------------------- | ----------------------------------------------------------------- |
|
||||
| `decode_request` | `decode_request`, `Params::decode` on a decoded Open, `verify_packet_hmac` |
|
||||
| `decode_open_reply` | `decode_open_reply`, unauthenticated and HMAC-authenticated |
|
||||
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
|
||||
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
|
||||
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
|
||||
|
||||
The invariant every target asserts is: **arbitrary bytes must never panic**.
|
||||
Returning `Err` is normal and expected; a successful decode is also normal.
|
||||
|
||||
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
|
||||
libFuzzer spends its time exploring protocol structure rather than
|
||||
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
|
||||
under this bound.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run decode_request
|
||||
```
|
||||
|
||||
Bounded smoke run, e.g. 30 seconds:
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run decode_request -- -max_total_time=30
|
||||
```
|
||||
|
||||
Build every target without running:
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz build
|
||||
```
|
||||
|
||||
## Corpus
|
||||
|
||||
Seed corpora live under `fuzz/corpus/<target>/`. They were generated once
|
||||
from `irtt-proto`'s own public encoders (`encode_request`, `encode_open_reply`,
|
||||
`encode_echo_reply`, `Params::encode`) by a throwaway local script, not copied
|
||||
from any external source, and not derived from upstream `heistp/irtt`. Each
|
||||
file is a small, representative wire packet (minimal Open, Close, Echo, HMAC
|
||||
variants, ordinary/recv-count/recv-window/midpoint Echo replies, and a few
|
||||
`Params` payloads including an unknown tag and `server_fill`).
|
||||
|
||||
## Crash artifacts
|
||||
|
||||
A failing case is written under `fuzz/artifacts/<target>/`. To replay it:
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run decode_request fuzz/artifacts/decode_request/<crash-file>
|
||||
```
|
||||
|
||||
## Isolation notes
|
||||
|
||||
- `fuzz/Cargo.toml` declares its own `[workspace]`, so it is never picked up
|
||||
by the root `[workspace]` in `../Cargo.toml`.
|
||||
- `libfuzzer-sys` and `arbitrary` are fuzz-only dependencies; they never
|
||||
appear in any published crate's dependency graph.
|
||||
- `fuzz/Cargo.lock` is committed for reproducible fuzzing builds, independent
|
||||
of the root `Cargo.lock`.
|
||||
Binary file not shown.
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user