Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641f8e18c3 | ||
|
|
d2d5f87e91 | ||
|
|
5b5e38a059 | ||
|
|
97bd903769 | ||
|
|
f02f9c0714 | ||
|
|
5957e395a2 | ||
|
|
5b5f5b2609 | ||
|
|
c8a911297d | ||
|
|
efa1313e95 | ||
|
|
8b7e793d50 | ||
|
|
e16336e51b | ||
|
|
04e1f11f3a | ||
|
|
3344407706 | ||
|
|
492f33408c | ||
|
|
f290e3a3f0 | ||
|
|
f96dc71beb | ||
|
|
e7caf7ec9e | ||
|
|
da433f9902 | ||
|
|
9bb3fd63d8 | ||
|
|
ea8734a7aa | ||
|
|
f08d40bfe9 | ||
|
|
583001f091 | ||
|
|
7aca022e11 | ||
|
|
3d2554cf2b | ||
|
|
e52c03f3b8 | ||
|
|
643e3b8b76 | ||
|
|
52f2a07490 | ||
|
|
d2a7acdec4 | ||
|
|
56fe30d0e2 | ||
|
|
d4c89ee22c | ||
|
|
5481009fe3 | ||
|
|
7d640793b6 | ||
|
|
9e8559d713 | ||
|
|
06fff94505 | ||
|
|
d7d3f58716 | ||
|
|
2a82951572 | ||
|
|
7da8d2141c | ||
|
|
bdb8c13d7d | ||
|
|
a04ea5727b | ||
|
|
ab5f1ac807 | ||
|
|
45b0c8549d | ||
|
|
18a8e307fb | ||
|
|
ab241c4a2b | ||
|
|
38e912632d | ||
|
|
39cbf7b6b5 | ||
|
|
eaa37d7568 | ||
|
|
da7472e10a | ||
|
|
9e6c10c083 | ||
|
|
988a360fef | ||
|
|
c28e63e67a | ||
|
|
c0a4f7a6db | ||
|
|
5bc27ef894 | ||
|
|
4c941a7d78 | ||
|
|
b98d1001de | ||
|
|
8e4424764d | ||
|
|
1e61f4168d | ||
|
|
22cead20dc | ||
|
|
e8de15d60e | ||
|
|
0a928dcf8e | ||
|
|
e13802ccdb | ||
|
|
d81784f0f6 | ||
|
|
b1b0b62848 | ||
|
|
5067ceeb1c | ||
|
|
66abb792ce | ||
|
|
1f7df1ebc9 | ||
|
|
1eed942423 | ||
|
|
0e32c76c78 | ||
|
|
e65d415c7d | ||
|
|
e7de8bdb30 | ||
|
|
b9cc3e2779 | ||
|
|
b7890f1190 | ||
|
|
bc9ea0fe99 | ||
|
|
2a2dd47e38 | ||
|
|
236b80db1d | ||
|
|
2cd8a7d60d | ||
|
|
784ce77354 | ||
|
|
6ebecaec85 | ||
|
|
664ef56b6e | ||
|
|
5cffd2e56a | ||
|
|
c9e539933b | ||
|
|
a6ba335302 |
No files matched your search
@@ -0,0 +1,313 @@
|
|||||||
|
|
||||||
|
name: Build binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
# Measure before stripping; package stripped copies separately.
|
||||||
|
CARGO_PROFILE_RELEASE_STRIP: none
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 2
|
||||||
|
matrix:
|
||||||
|
target:
|
||||||
|
- x86_64-musl
|
||||||
|
- i686-musl
|
||||||
|
- arm-musleabi
|
||||||
|
- arm-musleabihf
|
||||||
|
- armv7-musleabi
|
||||||
|
- armv7-musleabihf
|
||||||
|
- aarch64-musl
|
||||||
|
- mips-musl
|
||||||
|
- mipsel-musl
|
||||||
|
- mips64-muslabi64
|
||||||
|
- mips64el-muslabi64
|
||||||
|
- riscv64gc-musl
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: builtin:checkout
|
||||||
|
|
||||||
|
- name: Configure Rust directories
|
||||||
|
# Cross remote does not traverse hidden directories under /root.
|
||||||
|
run: |
|
||||||
|
echo "CARGO_HOME=$RUNNER_TEMP/cargo" >> "$GITHUB_ENV"
|
||||||
|
echo "RUSTUP_HOME=$RUNNER_TEMP/rustup" >> "$GITHUB_ENV"
|
||||||
|
echo "$RUNNER_TEMP/cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install standard Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
with:
|
||||||
|
toolchain: ${{ startsWith(matrix.target, 'mips') && 'nightly' || 'stable' }}
|
||||||
|
cache: false
|
||||||
|
build-warnings: warn
|
||||||
|
|
||||||
|
- name: Install nightly toolchain and standard library sources
|
||||||
|
run: |
|
||||||
|
rustup toolchain install nightly \
|
||||||
|
--profile minimal \
|
||||||
|
--component rust-src
|
||||||
|
|
||||||
|
- name: Install cross and binary tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends binutils llvm
|
||||||
|
cargo install --git https://github.com/cross-rs/cross --locked cross
|
||||||
|
echo "RUST_TARGET=${TARGET%-*}-unknown-linux-${TARGET#*-}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Detect statistics feature
|
||||||
|
run: |
|
||||||
|
if grep -Eq '^[[:space:]]*stats[[:space:]]*=' crates/irtt-app/Cargo.toml; then
|
||||||
|
echo "HAS_STATS_FEATURE=true" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "HAS_STATS_FEATURE=false" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build, verify, package, and compare
|
||||||
|
env:
|
||||||
|
# MIPS build-std needs cross GCC's CRT objects.
|
||||||
|
RUSTFLAGS: ${{ startsWith(matrix.target, 'mips') && '-C target-feature=+crt-static -C link-self-contained=no' || '-C target-feature=+crt-static' }}
|
||||||
|
CROSS_REMOTE: "true"
|
||||||
|
CROSS_REMOTE_COPY_FULL_TARGET_DIR: ${{ startsWith(matrix.target, 'mips') && 'true' || 'false' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
declare -A raw_size
|
||||||
|
declare -A stripped_size
|
||||||
|
declare -A compressed_size
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# MIPS requires nightly build-std via Cross.toml.
|
||||||
|
# Every other standard build uses stable.
|
||||||
|
standard_toolchain=stable
|
||||||
|
if [[ "$TARGET" == mips* ]]; then
|
||||||
|
standard_toolchain=nightly
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_variant() {
|
||||||
|
local variant="$1"
|
||||||
|
local toolchain="$2"
|
||||||
|
local features="$3"
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
local binary="target/${RUST_TARGET}/release/irtt-client"
|
||||||
|
local directory="dist/${variant}"
|
||||||
|
local artifact="dist/irtt-client-${TARGET}-${variant}.tar.gz"
|
||||||
|
|
||||||
|
echo "::group::Build ${variant} (${toolchain}, ${features})"
|
||||||
|
|
||||||
|
cross "+${toolchain}" build \
|
||||||
|
--release \
|
||||||
|
--locked \
|
||||||
|
--target "$RUST_TARGET" \
|
||||||
|
-p irtt-rs \
|
||||||
|
--bin irtt-client \
|
||||||
|
--no-default-features \
|
||||||
|
--features "$features" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Reject dynamically linked executables.
|
||||||
|
readelf --program-headers "$binary" > /tmp/program-headers.txt
|
||||||
|
readelf --dynamic "$binary" > /tmp/dynamic-section.txt
|
||||||
|
|
||||||
|
if grep -q INTERP /tmp/program-headers.txt ||
|
||||||
|
grep -q NEEDED /tmp/dynamic-section.txt; then
|
||||||
|
echo "Binary is not statically linked: ${variant}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$directory"
|
||||||
|
cp "$binary" "$directory/irtt-client"
|
||||||
|
|
||||||
|
raw_size[$variant]=$(stat -c %s "$binary")
|
||||||
|
|
||||||
|
llvm-strip --strip-all "$directory/irtt-client"
|
||||||
|
stripped_size[$variant]=$(
|
||||||
|
stat -c %s "$directory/irtt-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each archive contains an executable named irtt-client.
|
||||||
|
tar -czf "$artifact" -C "$directory" irtt-client
|
||||||
|
compressed_size[$variant]=$(stat -c %s "$artifact")
|
||||||
|
|
||||||
|
echo "::endgroup::"
|
||||||
|
}
|
||||||
|
|
||||||
|
human() {
|
||||||
|
numfmt --to=iec-i --suffix=B --format='%.1f' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Negative means smaller than the reference binary.
|
||||||
|
delta() {
|
||||||
|
awk -v base="$1" -v current="$2" 'BEGIN {
|
||||||
|
if (base == 0) {
|
||||||
|
printf "N/A"
|
||||||
|
} else {
|
||||||
|
printf "%+.1f%%", 100 * (current - base) / base
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
print_row() {
|
||||||
|
local variant="$1"
|
||||||
|
local label="$2"
|
||||||
|
local baseline="$3"
|
||||||
|
local comparison
|
||||||
|
|
||||||
|
if [[ "$variant" == "$baseline" ]]; then
|
||||||
|
comparison="Baseline"
|
||||||
|
else
|
||||||
|
comparison=$(delta \
|
||||||
|
"${compressed_size[$baseline]}" \
|
||||||
|
"${compressed_size[$variant]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '| %s | %s | %s | %s | %s |\n' \
|
||||||
|
"$label" \
|
||||||
|
"$(human "${raw_size[$variant]}")" \
|
||||||
|
"$(human "${stripped_size[$variant]}")" \
|
||||||
|
"$(human "${compressed_size[$variant]}")" \
|
||||||
|
"$comparison"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Standard builds: stable except MIPS.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
full_features=client
|
||||||
|
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
|
||||||
|
full_features="client,stats"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_variant full "$standard_toolchain" "$full_features"
|
||||||
|
|
||||||
|
standard_baseline=full
|
||||||
|
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
|
||||||
|
build_variant minimal "$standard_toolchain" client
|
||||||
|
standard_baseline=minimal
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Nightly baseline: identical compiler for all experiments.
|
||||||
|
#
|
||||||
|
# MIPS already uses nightly for standard builds, so reuse
|
||||||
|
# those binaries rather than compiling them twice.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ "$standard_toolchain" == "nightly" ]]; then
|
||||||
|
nightly_full=full
|
||||||
|
nightly_baseline="$standard_baseline"
|
||||||
|
else
|
||||||
|
nightly_full=nightly-full
|
||||||
|
build_variant nightly-full nightly "$full_features"
|
||||||
|
|
||||||
|
nightly_baseline=nightly-full
|
||||||
|
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
|
||||||
|
build_variant nightly-minimal nightly client
|
||||||
|
nightly_baseline=nightly-minimal
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Nightly size experiments, always enabled.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
# Rebuild std with the ordinary std feature set.
|
||||||
|
build_variant rebuilt-std nightly client \
|
||||||
|
-Z build-std
|
||||||
|
|
||||||
|
# Rebuild std without backtrace support.
|
||||||
|
# Retain normal panic unwinding.
|
||||||
|
build_variant no-backtrace nightly client \
|
||||||
|
-Z build-std \
|
||||||
|
-Z build-std-features=panic-unwind
|
||||||
|
|
||||||
|
# Aggressive option: terminate immediately on panic.
|
||||||
|
build_variant immediate-abort nightly client \
|
||||||
|
-Z panic-immediate-abort \
|
||||||
|
-Z build-std \
|
||||||
|
-Z build-std-features= \
|
||||||
|
--config 'profile.release.panic="immediate-abort"'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Job summary.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### irtt-client: ${TARGET}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "#### Standard builds"
|
||||||
|
echo
|
||||||
|
echo "Toolchain: \`${standard_toolchain}\`"
|
||||||
|
echo
|
||||||
|
echo '| Variant | Unstripped | Stripped | Compressed | Compressed vs baseline |'
|
||||||
|
echo '|:--|--:|--:|--:|--:|'
|
||||||
|
|
||||||
|
print_row full "Full" "$standard_baseline"
|
||||||
|
|
||||||
|
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
|
||||||
|
print_row minimal "Minimal" "$standard_baseline"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "#### Nightly experiments"
|
||||||
|
echo
|
||||||
|
echo '| Variant | Unstripped | Stripped | Compressed | Compressed vs baseline |'
|
||||||
|
echo '|:--|--:|--:|--:|--:|'
|
||||||
|
|
||||||
|
print_row "$nightly_full" "Nightly full" "$nightly_baseline"
|
||||||
|
|
||||||
|
if [[ "$HAS_STATS_FEATURE" == "true" ]]; then
|
||||||
|
print_row "$nightly_baseline" "Nightly minimal" "$nightly_baseline"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_row rebuilt-std "Rebuilt std" "$nightly_baseline"
|
||||||
|
print_row no-backtrace "No backtrace" "$nightly_baseline"
|
||||||
|
print_row immediate-abort "Immediate abort" "$nightly_baseline"
|
||||||
|
|
||||||
|
if [[ "$standard_toolchain" == "stable" ]]; then
|
||||||
|
echo
|
||||||
|
echo "Nightly vs stable baseline (compressed): $(delta \
|
||||||
|
"${compressed_size[$standard_baseline]}" \
|
||||||
|
"${compressed_size[$nightly_baseline]}")"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "MIPS: standard and nightly baselines are identical."
|
||||||
|
echo "Cross.toml already enables build-std for these targets."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$HAS_STATS_FEATURE" != "true" ]]; then
|
||||||
|
echo
|
||||||
|
echo "Statistics feature not detected; client includes statistics."
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Upload binaries
|
||||||
|
# Gitea's v3 artifact protocol.
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: irtt-client-${{ matrix.target }}
|
||||||
|
path: dist/irtt-client-${{ matrix.target }}-*.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
if: matrix.musl
|
if: matrix.musl
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends musl-tools
|
sudo apt-get install -y --no-install-recommends musl-tools
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GITEA_REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
# The automatic per-job token comes back unauthorized for package
|
# The automatic per-job token comes back unauthorized for package
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GHCR_REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
username: ${{ secrets.GHCR_USERNAME }}
|
username: ${{ secrets.GHCR_USERNAME }}
|
||||||
|
|||||||
@@ -34,12 +34,35 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
|
- name: Install Go toolchain
|
||||||
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
|
with:
|
||||||
|
go-version: '1.27.3'
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install netem dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends iproute2 python3 util-linux
|
||||||
|
|
||||||
- name: Check host tools and network administration access
|
- name: Check host tools and network administration access
|
||||||
run: |
|
run: |
|
||||||
ip -V
|
ip -V
|
||||||
tc -V
|
tc -V
|
||||||
|
nsenter --version
|
||||||
go version
|
go version
|
||||||
sudo -n true
|
# Exercise the namespace, veth, and qdisc operations the harness needs.
|
||||||
|
# sudo/NET_ADMIN alone do not establish mount/namespace permissions.
|
||||||
|
sudo -n bash -euo pipefail <<'SH'
|
||||||
|
probe_ns="irtt-preflight-$$"
|
||||||
|
ip netns add "$probe_ns"
|
||||||
|
trap 'ip netns delete "$probe_ns"' EXIT
|
||||||
|
nsenter --net="/run/netns/$probe_ns" -- sh -ec '
|
||||||
|
ip link add probe0 type veth peer name probe1
|
||||||
|
tc qdisc add dev probe0 root netem delay 1ms
|
||||||
|
tc qdisc show dev probe0
|
||||||
|
'
|
||||||
|
SH
|
||||||
|
|
||||||
- name: Install upstream irtt
|
- name: Install upstream irtt
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
|
||||||
- name: Install cargo-deny
|
- name: Install cargo-deny
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||||
# both workflow directories through its GitHub Actions rule set.
|
# both workflow directories through its GitHub Actions rule set.
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||||
with:
|
with:
|
||||||
persona: regular
|
persona: regular
|
||||||
inputs: |
|
inputs: |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
target: aarch64-unknown-linux-musl
|
target: aarch64-unknown-linux-musl
|
||||||
musl: true
|
musl: true
|
||||||
|
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-26.04-arm
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
if: matrix.musl
|
if: matrix.musl
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends musl-tools
|
sudo apt-get install -y --no-install-recommends musl-tools
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
# separate terms of service, privacy policy, and support
|
# separate terms of service, privacy policy, and support
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
|
# Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
|
||||||
# in addition to a PR check which fails if new vulnerabilities are introduced.
|
|
||||||
#
|
#
|
||||||
# For more examples and options, including how to ignore specific vulnerabilities,
|
# For more examples and options, including how to ignore specific vulnerabilities,
|
||||||
# see https://google.github.io/osv-scanner/github-action/
|
# see https://google.github.io/osv-scanner/github-action/
|
||||||
@@ -20,6 +19,7 @@ on:
|
|||||||
- cron: "45 8 * * 1"
|
- cron: "45 8 * * 1"
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Require writing security events to upload SARIF file to security tab
|
# Require writing security events to upload SARIF file to security tab
|
||||||
@@ -29,21 +29,12 @@ permissions:
|
|||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Both pinned workflows default to a recursive scan of ./, including fuzz/.
|
||||||
|
# OSV v2 excludes the root Git repository unless --include-git-root is set;
|
||||||
|
# the old --skip-git flag is no longer supported.
|
||||||
scan-scheduled:
|
scan-scheduled:
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
|
||||||
with:
|
|
||||||
# Example of specifying custom arguments
|
|
||||||
scan-args: |-
|
|
||||||
-r
|
|
||||||
--skip-git
|
|
||||||
./
|
|
||||||
scan-pr:
|
scan-pr:
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
|
||||||
with:
|
|
||||||
# Example of specifying custom arguments
|
|
||||||
scan-args: |-
|
|
||||||
-r
|
|
||||||
--skip-git
|
|
||||||
./
|
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
- name: Upload Clippy SARIF
|
- name: Upload Clippy SARIF
|
||||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: rust-clippy-results.sarif
|
sarif_file: rust-clippy-results.sarif
|
||||||
|
|||||||
+203
-2
@@ -10,6 +10,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-proto
|
## irtt-proto
|
||||||
|
|
||||||
|
### 0.5.2
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Exported `VERSION`, the compiled crate's Cargo package version, for applications reporting their component versions. Wire behavior is unchanged.
|
||||||
|
|
||||||
### 0.5.1
|
### 0.5.1
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -34,6 +40,56 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-client
|
## irtt-client
|
||||||
|
|
||||||
|
### 0.7.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Opt-in automatic session reconnection in the Tokio managed driver and `BlockingManagedClient`. Continuous measurement runs can retry initial connection failures, failed or peer-closed sessions, and server-limited session completion. Silence detection reconnects a session that stops replying while probes are being sent.
|
||||||
|
- Each desired target retries independently, leaving healthy targets running. Exponential backoff doubles from `reconnect_initial_delay` (default 250 ms) to `reconnect_max_delay` (default 10 s); a valid reply restores the initial delay. Reopening resolves the original endpoint again. Target removal and explicit shutdown cancel recovery.
|
||||||
|
- Exported `VERSION` for compiled component-version reporting.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Stopping or replacing a target during session opening cancels the pending open immediately, instead of waiting for its retry schedule to finish.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- `ManagedClientConfig` gains `reconnect_timeout`, `reconnect_initial_delay`, and `reconnect_max_delay`; update complete struct literals or use `..Default::default()`. Reconnection is disabled by default (`reconnect_timeout: None`) and is valid only for continuous normal measurement mode, with a positive schedulable silence timeout. Retry delays must be positive and schedulable, and the maximum must be at least twice the initial delay.
|
||||||
|
- `ManagedConfigError` gains `InvalidReconnectPolicy` and `InvalidReconnectDelays`; downstream exhaustive matches must handle them. Delay validation applies even when recovery is disabled.
|
||||||
|
- A reconnecting target returns to pending/opening status rather than producing a terminal outcome for each retry. Consumers must handle repeated session-start events for the same target instance and reset session-bound statistics and sequence tracking on each new session.
|
||||||
|
- Use `irtt-stats` 0.6 with this client version when exchanging `ClientEvent` values. Tokio remains optional; the default low-level client remains runtime-free. No wire-protocol migration is required.
|
||||||
|
|
||||||
|
### 0.6.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Added `ManagedClientHandle::subscribe_status()` and `ManagedStatusSubscription` for observing durable lifecycle and completion state independently of the lossy event stream. The current snapshot is immediately readable, change notifications track subsequent updates, and terminal target status includes its completed outcome.
|
||||||
|
- Managed targets can override the shared address-family policy while retaining the original hostname for resolution on each new generation.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `ClientConfig` is now reusable across endpoints: `Client::connect` and `AsyncClient::connect` take the endpoint separately, with configuration grouped into `socket`, `request` (`SessionRequest`), and `open` (`OpenPolicy`). `AddressFamily` replaces the separate socket family flags, and blocking receive timeouts are configured through `Client::set_recv_timeout`.
|
||||||
|
- Explicit `Authentication`, `HmacKey`, and managed `TargetAuth` replace `ClientConfig.hmac_key` and `ClientAuthConfig`. Key bytes are shared across clones and redacted from debug output. Switching between inherited and explicit authentication or address-family settings starts a new target generation, even when the effective settings are equal.
|
||||||
|
- `NegotiationResult` replaces `NegotiatedParams`, separating typed `AcceptedSessionParameters` from exact peer-returned wire parameters and accepted `NegotiationChange` records (formerly `NegotiationRestriction`). Open outcomes and lifecycle events now use tuple variants carrying `SessionStarted` or `NoTestCompleted` payloads.
|
||||||
|
- Low-level clients now leave probe cadence and run duration entirely to the caller. `next_send_deadline` and `is_run_complete` were removed; managed clients continue to provide scheduling and finite-run completion. Low-level callers can inspect `negotiation()`, `has_pending_probes()`, and `next_probe_timeout_deadline()` to drive their own event loop.
|
||||||
|
- Probe sends now return `Result<SendReceipt, SendProbeError>`, distinguishing failures before socket acceptance from failures after a probe was committed. Receipts convert to `ClientEvent::EchoSent`; its `scheduled_at` and `timer_error` fields are now optional and absent for low-level sends.
|
||||||
|
- Stop receipts resolve when the stop request is durably observed or the task becomes terminal; callers must still await task completion to observe finished cleanup.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Finite managed runs now reach their negotiated end under socket backpressure, even when no pending probe timeout remains to wake the driver. Staggered pacing no longer repeatedly wakes idle or draining targets that have no send deadline.
|
||||||
|
- Blocking receive operations now return lifecycle errors immediately when no session is open, instead of blocking for a datagram or returning empty success after a timeout.
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
- Managed drain-deadline lookup no longer scans retained timed-out probes, keeping lookup work bounded as retained probe state grows.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- The configuration, authentication, negotiation, lifecycle-event, and send APIs above are breaking changes without compatibility aliases. Low-level callers must enforce their own cadence and duration or use a managed client.
|
||||||
|
- `AcceptedSessionParameters::dscp` is a six-bit codepoint; `NegotiationResult::peer_params.dscp` remains the raw traffic-class byte. Accepted durations and intervals use Rust duration types rather than wire nanosecond integers.
|
||||||
|
- Tokio remains optional, and the default client build remains runtime-free. These API changes do not introduce a wire-protocol migration.
|
||||||
|
|
||||||
### 0.5.3
|
### 0.5.3
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -74,6 +130,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-server
|
## irtt-server
|
||||||
|
|
||||||
|
### 0.5.4
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Exported `VERSION`, the compiled crate's Cargo package version, for applications reporting their component versions. Server policy and wire behavior are unchanged.
|
||||||
|
|
||||||
### 0.5.3
|
### 0.5.3
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -119,6 +181,46 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-stats
|
## irtt-stats
|
||||||
|
|
||||||
|
### 0.6.0
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- Updated the public `irtt-client` dependency to 0.7. Applications passing `ClientEvent` values into this crate must upgrade both dependencies together; client 0.6 and 0.7 event types come from different crate versions and cannot be mixed.
|
||||||
|
- Added signed nanosecond changes to `IpdvPairUpdate`: `rtt_ipdv_ns`, `send_ipdv_ns`, and `receive_ipdv_ns`. Existing `Duration` fields and aggregate IPDV statistics remain absolute magnitudes. Update complete struct literals to include the new fields.
|
||||||
|
- Exported `VERSION` for compiled component-version reporting.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Directional IPDV is available with midpoint timestamps, using the midpoint for both server endpoints.
|
||||||
|
- Per-packet IPDV preserves the sign of the delay change instead of reporting only its magnitude.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- Collectors remain scoped to one target session. Reset or replace a collector when the managed client reconnects; this release does not aggregate across session sequence resets.
|
||||||
|
|
||||||
|
### 0.5.2
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `TimeStats` is now re-exported from `measurement-stats`, retaining the existing `irtt_stats::TimeStats` import path, fields, and methods.
|
||||||
|
- Timer-error statistics now omit sends without scheduling metadata instead of recording an artificial measurement.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Time-based rolling windows now expire backdated events correctly, including expired events inside retained arrival history. Older timestamps cannot move the expiry anchor backwards or restore expired history.
|
||||||
|
- Rolling upstream and downstream loss now use the server receive-count increase over the retained packet-event interval instead of comparing window counts against a cumulative server count.
|
||||||
|
- Rolling directional-loss estimates are unavailable when counter observations are missing, stale, discontinuous, or separated by an interior gap in the time window, rather than reporting misleading values.
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
- Indexed rolling-window expiry avoids full-window scans on insertion and reclaims expired entries without accumulating tombstones.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- This release consumes `irtt-client` 0.6 events; applications using both crates must upgrade them together.
|
||||||
|
- Unavailable rolling directional-loss packet estimates are `None`; their percentage fields remain `0.0`. Valid signed estimates remain possible, and raw server receive counts retain their cumulative meaning.
|
||||||
|
- Time-based rolling windows expire when new events arrive, retain events exactly at the cutoff, and have no event-count cap. Use `rolling_count` when event storage needs a fixed bound.
|
||||||
|
|
||||||
### 0.5.1
|
### 0.5.1
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -138,6 +240,100 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-rs
|
## irtt-rs
|
||||||
|
|
||||||
|
### 0.10.2
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Client `--events` filters output by event type, using comma-separated names or repeated options. For example, `--events echo_reply` prints only echo replies. Omitting the option preserves normal output behavior.
|
||||||
|
- Explicitly selecting `echo_sent` with `--events` shows those events even with default table columns.
|
||||||
|
|
||||||
|
### 0.10.1
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- The client now responds to SIGINT and SIGTERM while stdout is blocked, so a stalled output reader no longer prevents shutdown.
|
||||||
|
- Interrupted measurements preserve partial statistics summaries when output can be written. A shared 250 ms grace period bounds output flushing during shutdown.
|
||||||
|
|
||||||
|
### 0.10.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Continuous CLI recovery with `--continuous --reconnect` (or `--duration 0 --reconnect`). Failed initial opens, failed or closed sessions, server-limited completion, and reply silence trigger independent per-target retries. Healthy targets keep running, including in multi-target and `--targets-stdin` runs; removing a desired target cancels its retries.
|
||||||
|
- `--reconnect-timeout` configures reply-silence detection while sending (default 10 s). `--reconnect-initial-delay` and `--reconnect-max-delay` configure per-target exponential backoff (defaults 250 ms and 10 s); the maximum must be at least twice the initial delay. Valid replies reset the backoff. Explicit stops do not retry.
|
||||||
|
- `--no-stats` disables client statistics collection, retention warnings, and final summaries while retaining measurement events and structured output columns. Statistics-dependent fields have absent values.
|
||||||
|
- Optional `stats` Cargo feature, enabled by default and required by `tui`. Build a lightweight client without statistics using `cargo build --release -p irtt-rs --bin irtt-client --no-default-features --features client`; use `--features client,stats` for a client-only build with statistics.
|
||||||
|
- `--continuous` as an explicit alias for zero duration, `-d` for duration, `-i` for interval, `-l` for packet length, client `-f` for output format, and `-v` for verbose output. Client/TUI durations accept fractional and composite values such as `2.5s` and `1m30s`.
|
||||||
|
- Client/TUI `--timeouts` configures the session-opening attempt schedule independently of session reconnection.
|
||||||
|
- Client `-n` / `--no-test` negotiates parameters and exits without measurement probes, using the selected output format.
|
||||||
|
- `--version` on the dispatcher and applets; applets also report their compiled library versions and whether client statistics are disabled.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Signed per-packet IPDV is preserved in output; directional IPDV is available with midpoint timestamps.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- Reconnection is opt-in for the CLI and requires continuous measurement mode; it cannot be combined with `--no-test`. Statistics reset on every reopened session, so summaries describe only the latest session per target, not the whole continuous run. A warning explains this when recovery and statistics are both enabled. Continuous TUI recovery remains enabled as before.
|
||||||
|
- Statistics are no longer implied by the `client` feature. Existing custom builds using `--no-default-features --features client` must add `stats` to retain collection and summaries. `--no-stats` is accepted in builds without statistics; output schemas are preserved, with absent IPDV values.
|
||||||
|
- Nonzero duration values require units; zero (including `0s`) still selects continuous mode. `--continuous` and an explicit `--duration` are mutually exclusive.
|
||||||
|
- Application-library users must account for the added client argument/configuration fields and optional statistics feature. Dependencies are now client 0.7 and stats 0.6, with proto >=0.5.2 and server >=0.5.4 for component-version reporting. MSRV remains Rust 1.88.
|
||||||
|
|
||||||
|
### 0.9.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Client and TUI `--bind-addr IP[:PORT]` selects a local source address and optional UDP port. Omitted ports and port `0` use an ephemeral port; IPv6 addresses with an explicit port use `[ADDR]:PORT`. The bind address must match the target's address family, and fixed ports can conflict across multiple target sockets.
|
||||||
|
- Client and TUI `--bind-device DEVICE` binds sockets to a network interface on Linux, Android, and Fuchsia.
|
||||||
|
- Client and TUI `--mark MARK` sets a socket firewall mark for policy routing or firewall rules on Linux, Android, and Fuchsia. It accepts decimal and `0x`-prefixed hexadecimal unsigned 32-bit values.
|
||||||
|
- Client and TUI `--fib FIB` selects a routing table on FreeBSD, accepting a decimal unsigned 32-bit value.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Restored CLI compatibility with upstream IRTT for hexadecimal HMAC keys: client, TUI, and server `--hmac`, server `IRTT_SERVER_HMAC`, and client/TUI per-target `@hmac=KEY` accept a lowercase `0x` prefix followed by hexadecimal bytes, with either case allowed for digits. Literal UTF-8 keys remain supported; the HMAC-MD5 algorithm is unchanged.
|
||||||
|
- Server argument debug output now redacts HMAC keys, matching the client and TUI. Server help hides the `IRTT_SERVER_HMAC` value, and invalid global HMAC keys are rejected during configuration preparation without echoing key material.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- Keys beginning with literal `0x` are now interpreted as hexadecimal. Empty keys, empty hexadecimal keys, odd-length hexadecimal strings, and invalid hexadecimal digits are rejected. Per-target `@hmac=` continues to disable authentication explicitly.
|
||||||
|
|
||||||
|
### 0.8.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Added mutually exclusive `-4` / `--ipv4`, `-6` / `--ipv6`, and `--dual-stack` options to the client and TUI. Dual-stack hostnames expand into independent `/v4` and `/v6` targets with separate sessions, statistics, and failures; explicit IP literals remain single targets.
|
||||||
|
- Continuous TUI runs now reconnect independently after target failures, peer closure, or server-limited session completion while healthy targets continue uninterrupted. The TUI remains running even when every target fails.
|
||||||
|
- Added target selection and a scrollable details sheet showing negotiation, counters, timing statistics, warnings, and recent events.
|
||||||
|
- Graph axes now display local wall-clock timestamps, including fractional seconds or dates where appropriate.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- Replaced separate Graph and Dashboard views with one dashboard comparing all targets. Target rows show effective RTT, cumulative loss, jitter, and the age of the last primary reply. `Tab` / `Shift-Tab` selects a target; `d` opens details, with `g` retained as an alias.
|
||||||
|
- Reconnecting targets retain their logical row and graph history, reset session statistics for each new generation, and leave gaps between generations.
|
||||||
|
- Increased graph retention from 100,000 to 500,000 samples per target and extended the maximum graph window from one hour to 24 hours.
|
||||||
|
- Clearing graph history now clears all targets and returns to live view while preserving latest samples and statistics.
|
||||||
|
- Client and TUI frontends now react asynchronously to measurements, status changes, input, and shutdown, removing periodic polling delays.
|
||||||
|
- The application library's `run_stream`, `run_tui`, and `prepare_managed_run` entry points are now asynchronous.
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
- Graph rendering selects samples within the visible viewport instead of scanning the full retained history, with interpolation at viewport boundaries.
|
||||||
|
- Reduced graph-history memory usage per sample substantially, allowing retention to increase from 100,000 to 500,000 samples per target without proportional memory growth.
|
||||||
|
- Increased regular TUI refresh frequency to 100 ms for smoother updates.
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
|
||||||
|
- Automatic reconnect applies to continuous TUI runs, not ordinary `irtt-client` runs or finite TUI runs. Explicit stops and no-test completion do not trigger retries.
|
||||||
|
- Dual-stack expansion changes target labels and can double probe traffic per hostname. The stdin limit of 128 desired targets applies after expansion; generated labels must remain unique.
|
||||||
|
- Address families are discovered when a declaration is added. Each new session resolves the original hostname within its assigned family; newly appearing families are not added automatically. Unchanged stdin declarations retain their discovered families, while removal and re-addition performs fresh discovery.
|
||||||
|
- New stdin declarations are prepared asynchronously while existing measurements continue. Preparation errors stop the stream gracefully without applying a partial target set.
|
||||||
|
- The larger graph cap permits higher eventual memory use: approximately 42 MiB per target for retained graph samples at the cap on 64-bit macOS, excluding statistics and rendering buffers.
|
||||||
|
- Application-library callers must await client/TUI run and preparation functions and supply the new shutdown argument to the run functions.
|
||||||
|
- `ClientArgs::prepare` and `TuiArgs::prepare` are also asynchronous; `TargetPreparation::prepare` replaces `prepare_managed_targets`, and `parse_stdin_target_set` now returns `TargetSpec` values for preparation.
|
||||||
|
- The application's former `TargetAuth` type was replaced by `irtt_client::managed::TargetAuth`.
|
||||||
|
- The public output wrappers `EventRenderStats` and `IpdvPair` were removed in favor of `irtt_stats::EventStatsUpdate` and `IpdvPairUpdate`.
|
||||||
|
- `DEFAULT_RECV_TIMEOUT`, `is_shutdown_requested`, and `TuiArgs::timestamp_mode` were removed. `install_signal_handler` is now available only with the `server` feature; client/TUI embedding uses `install_async_signal_handler`.
|
||||||
|
- MSRV remains Rust 1.88. Existing output column names and formats are preserved.
|
||||||
|
|
||||||
### 0.7.1
|
### 0.7.1
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
@@ -220,10 +416,15 @@ Starting with this release, crates are versioned and tagged independently
|
|||||||
rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
|
rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
|
||||||
(e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released
|
(e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released
|
||||||
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
|
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
|
||||||
libraries published to [crates.io](https://crates.io); their tags exist to
|
libraries published to [crates.io](https://crates.io); publication is a separate
|
||||||
|
manual Cargo step. Their tags exist to
|
||||||
identify the exact source for a given crates.io release and do not produce a
|
identify the exact source for a given crates.io release and do not produce a
|
||||||
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
|
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
|
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/)
|
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
|
to build and publish a GitHub Release with platform binaries. On Gitea, the
|
||||||
|
same application tag triggers the server Docker image workflow, publishing
|
||||||
|
`X.Y.Z` and `latest` to Gitea and GHCR. Library tags
|
||||||
|
trigger the Gitea Semver workflow, but neither binary nor image publication.
|
||||||
|
The application tag also triggers that Semver check. The historical
|
||||||
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
|
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
|
||||||
Generated
+387
-141
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -114,11 +123,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec 0.8.0",
|
"bit-vec 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -129,9 +138,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-vec"
|
name = "bit-vec"
|
||||||
version = "0.8.0"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
|
||||||
|
dependencies = [
|
||||||
|
"borsh",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
@@ -172,6 +185,30 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "borsh"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||||
|
dependencies = [
|
||||||
|
"borsh-derive",
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "borsh-derive"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -190,6 +227,12 @@ version = "1.25.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "castaway"
|
name = "castaway"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -199,6 +242,16 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -211,6 +264,28 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -221,6 +296,15 @@ dependencies = [
|
|||||||
"clap_derive",
|
"clap_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap-num"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "822c4000301ac390e65995c62207501e3ef800a1fc441df913a5e8e4dc374816"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -242,7 +326,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -292,6 +376,18 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -301,6 +397,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "critical-section"
|
name = "critical-section"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -317,6 +422,7 @@ dependencies = [
|
|||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"document-features",
|
"document-features",
|
||||||
|
"futures-core",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
@@ -376,9 +482,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.2"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
@@ -403,7 +509,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -414,7 +520,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -425,9 +531,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
@@ -496,15 +602,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.18.0"
|
version = "1.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
@@ -552,6 +658,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "finl_unicode"
|
name = "finl_unicode"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -631,17 +743,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
]
|
"rand_core 0.10.1",
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -676,6 +778,12 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -685,6 +793,30 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -692,10 +824,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indexmap"
|
||||||
version = "2.0.7"
|
version = "2.14.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
@@ -710,12 +852,12 @@ dependencies = [
|
|||||||
"indoc",
|
"indoc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.3"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
@@ -737,7 +879,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-proto"
|
name = "irtt-proto"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -748,22 +890,28 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.7.1"
|
version = "0.10.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap-num",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
|
"futures-core",
|
||||||
|
"hex",
|
||||||
|
"humantime",
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
"irtt-stats",
|
"irtt-stats",
|
||||||
|
"nix 0.31.3",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-server"
|
name = "irtt-server"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
@@ -775,7 +923,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"measurement-stats",
|
"measurement-stats",
|
||||||
@@ -816,11 +964,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kasuari"
|
name = "kasuari"
|
||||||
version = "0.4.12"
|
version = "0.4.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
@@ -833,15 +981,15 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.191"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
@@ -866,9 +1014,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
@@ -891,17 +1039,17 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac_address"
|
name = "mac_address"
|
||||||
version = "1.1.8"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix 0.29.0",
|
"nix 0.31.3",
|
||||||
"winapi",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -949,9 +1097,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.3"
|
version = "1.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -969,7 +1117,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1023,18 +1170,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num_threads"
|
name = "num_threads"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2-encode",
|
"objc2-encode",
|
||||||
]
|
]
|
||||||
@@ -1124,19 +1271,20 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest"
|
name = "pest"
|
||||||
version = "2.9.2"
|
version = "2.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"pest_stack_guard",
|
||||||
"ucd-trie",
|
"ucd-trie",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_derive"
|
name = "pest_derive"
|
||||||
version = "2.9.2"
|
version = "2.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_generator",
|
"pest_generator",
|
||||||
@@ -1144,26 +1292,36 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_generator"
|
name = "pest_generator"
|
||||||
version = "2.9.2"
|
version = "2.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_meta",
|
"pest_meta",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_meta"
|
name = "pest_meta"
|
||||||
version = "2.9.2"
|
version = "2.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pest_stack_guard"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2"
|
||||||
|
dependencies = [
|
||||||
|
"psm",
|
||||||
|
"stacker",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -1230,17 +1388,17 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "proc-macro-crate"
|
||||||
version = "0.2.21"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy",
|
"toml_edit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1254,16 +1412,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proptest"
|
name = "proptest"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set 0.8.0",
|
"bit-set 0.11.1",
|
||||||
"bit-vec 0.8.0",
|
"bit-vec 0.10.1",
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
|
"chacha20",
|
||||||
|
"core_detect",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.9.5",
|
"rand 0.10.3",
|
||||||
"rand_chacha",
|
|
||||||
"rand_xorshift",
|
"rand_xorshift",
|
||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
"rusty-fork",
|
"rusty-fork",
|
||||||
@@ -1271,6 +1430,15 @@ dependencies = [
|
|||||||
"unarray",
|
"unarray",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psm"
|
||||||
|
version = "0.1.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1309,22 +1477,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.5"
|
version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"getrandom 0.4.3",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.10.1",
|
||||||
]
|
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
@@ -1335,20 +1493,17 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_xorshift"
|
name = "rand_xorshift"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1376,7 +1531,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
"critical-section",
|
"critical-section",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown",
|
||||||
"itertools",
|
"itertools",
|
||||||
"kasuari",
|
"kasuari",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -1439,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown",
|
||||||
"indoc",
|
"indoc",
|
||||||
"instability",
|
"instability",
|
||||||
"itertools",
|
"itertools",
|
||||||
@@ -1501,9 +1656,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -1575,7 +1730,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1585,10 +1740,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -1634,9 +1795,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.16.2"
|
version = "1.16.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
@@ -1648,6 +1809,19 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stacker"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"psm",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "static_assertions"
|
name = "static_assertions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1711,9 +1885,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.6"
|
version = "3.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1846,7 +2020,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1872,9 +2046,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.54.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
@@ -1892,7 +2066,37 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.17+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1915,9 +2119,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.26"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
@@ -1938,9 +2142,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
@@ -1950,9 +2154,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.26.1"
|
version = "1.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic",
|
"atomic",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -2031,7 +2235,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.9",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2138,12 +2342,65 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -2153,28 +2410,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.8.59"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.8.59"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
+4
-5
@@ -14,13 +14,12 @@ edition = "2021"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
repository = "https://github.com/Lochnair/irtt-rs"
|
repository = "https://github.com/Lochnair/irtt-rs"
|
||||||
homepage = "https://github.com/Lochnair/irtt-rs"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.5.2", path = "crates/irtt-proto" }
|
||||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
irtt-client = { version = "0.7.0", path = "crates/irtt-client" }
|
||||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
irtt-server = { version = "0.5.4", path = "crates/irtt-server" }
|
||||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
irtt-stats = { version = "0.6.0", path = "crates/irtt-stats" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
[build]
|
||||||
|
# MIPS musl std expects libunwind; cross GCC provides libgcc_eh.
|
||||||
|
pre-build = [
|
||||||
|
'''
|
||||||
|
for cc in /usr/local/bin/mips*-gcc; do
|
||||||
|
[ -x "$cc" ] || continue
|
||||||
|
archive=$("$cc" -print-file-name=libgcc_eh.a)
|
||||||
|
test -f "$archive" || exit 1
|
||||||
|
ln -sf libgcc_eh.a "$(dirname "$archive")/libunwind.a"
|
||||||
|
done
|
||||||
|
''',
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.riscv64gc-unknown-linux-musl]
|
||||||
|
image = "ghcr.io/cross-rs/riscv64gc-unknown-linux-musl:main"
|
||||||
|
|
||||||
|
[target.mips-unknown-linux-musl]
|
||||||
|
image = "ghcr.io/cross-rs/mips-unknown-linux-musl:main"
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
|
|
||||||
|
[target.mipsel-unknown-linux-musl]
|
||||||
|
image = "ghcr.io/cross-rs/mipsel-unknown-linux-musl:main"
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
|
|
||||||
|
[target.mips64-unknown-linux-muslabi64]
|
||||||
|
image = "ghcr.io/cross-rs/mips64-unknown-linux-muslabi64:main"
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
|
|
||||||
|
[target.mips64el-unknown-linux-muslabi64]
|
||||||
|
image = "ghcr.io/cross-rs/mips64el-unknown-linux-muslabi64:main"
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
|
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
|
||||||
|
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +1,68 @@
|
|||||||
# irtt-rs
|
# irtt-rs
|
||||||
|
|
||||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
|
`irtt-rs` is an IRTT-compatible client and server written in Rust. You can use
|
||||||
finite or continuous UDP latency probing, plus the server side those probes
|
it to measure UDP latency for a fixed duration or leave it running
|
||||||
run against.
|
continuously, and run your own server for the probes to talk to.
|
||||||
|
|
||||||
This is not the upstream IRTT project. For the original implementation,
|
The original IRTT implementation is [heistp/irtt](https://github.com/heistp/irtt),
|
||||||
protocol background, and broader documentation, see
|
which also has protocol background and more documentation. This project is
|
||||||
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
|
independent, so the installed dispatcher is named `irtt-rs` to keep it from
|
||||||
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
|
conflicting with or being mistaken for `irtt`.
|
||||||
mistaken for it.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Finite and continuous probe sessions
|
- Finite and continuous probe sessions
|
||||||
* Concurrent probing of multiple targets, with staggered or burst pacing
|
- Concurrent probing of multiple targets, with staggered or burst pacing
|
||||||
* Reusable client library with a structured event stream (blocking, Tokio,
|
- Reusable client library with a structured event stream (blocking, Tokio,
|
||||||
or managed)
|
or managed)
|
||||||
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
|
- Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
|
||||||
monitoring systems, with selectable output columns
|
monitoring systems, with selectable output columns
|
||||||
* Optional terminal UI
|
- Optional terminal UI
|
||||||
* Reusable Tokio-native server library
|
- Reusable Tokio-native server library
|
||||||
* UDP server applet with sensible zero-argument defaults and explicit bind
|
- UDP server applet you can run without arguments, with options for bind
|
||||||
and session-policy options
|
addresses and session policy when you need them
|
||||||
* Router-friendly multi-applet binary layout
|
- A single binary that can run several applets, useful on routers
|
||||||
|
|
||||||
## Comparison with upstream IRTT
|
## Comparison with upstream IRTT
|
||||||
|
|
||||||
`irtt-rs` is an independent, compatible reimplementation, not a successor or
|
`irtt-rs` is useful if you want to watch several targets at once, compare
|
||||||
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
|
them in a terminal dashboard, or feed selected measurements into a script.
|
||||||
remains the original implementation. Interoperability is a project goal; see
|
You can leave it running with statistics and automatic reconnection, use
|
||||||
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
|
its Rust libraries in your own application, or deploy a single binary on
|
||||||
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
|
a router.
|
||||||
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
|
Upstream IRTT is much more mature, and if you don't need the features
|
||||||
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
|
specific to `irtt-rs`, you should probably use it. It also has more tools
|
||||||
This project's own interop testing and protocol baseline are currently
|
for working with saved results and controlling how probes run, including
|
||||||
pinned to the prior release, v0.9.1; the wire protocol version number is
|
replaying saved JSON reports, timer and clock diagnostics, and client
|
||||||
unchanged between the two, but v0.9.2 has not itself been exercised against
|
payload fill and timing algorithms.
|
||||||
this project's interop harness.
|
|
||||||
|
|
||||||
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
|
See the [detailed comparison](docs/INTEROP_COMPARISON.md) for individual
|
||||||
| --- | --- | --- |
|
features, version coverage, and black-box interoperability testing.
|
||||||
| 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
|
## Installation
|
||||||
|
|
||||||
The build requires Rust 1.88 or newer.
|
Prebuilt binaries for supported platforms are available on the
|
||||||
|
[GitHub Releases](https://github.com/Lochnair/irtt-rs/releases) page.
|
||||||
|
|
||||||
Install the latest release from crates.io:
|
Alternatively, install from crates.io using Rust 1.88 or newer:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install irtt-rs --locked
|
cargo install irtt-rs --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
|
This installs the `irtt-rs` dispatcher along with dedicated `irtt-client`,
|
||||||
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
|
`irtt-tui`, and `irtt-server` binaries. If you only need the client, you can
|
||||||
isolation with `--no-default-features` plus the applet's own feature, for
|
disable the other applets and statistics:
|
||||||
example a lightweight client-only install:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install irtt-rs --locked --no-default-features --features client
|
cargo install irtt-rs --locked --no-default-features --features client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This installs `irtt-client` and the `irtt-rs` dispatcher. Add `stats`
|
||||||
|
(`--features client,stats`) to retain statistics in a client-only build.
|
||||||
|
Statistics are enabled by default; the TUI always enables them.
|
||||||
|
|
||||||
For development, or to install directly from a checkout:
|
For development, or to install directly from a checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -104,44 +77,80 @@ Probe a server:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||||
|
|
||||||
|
# Negotiate parameters without sending measurement probes
|
||||||
|
irtt-client --no-test netperf-eu.bufferbloat.net:2112
|
||||||
```
|
```
|
||||||
|
|
||||||
Probe continuously until interrupted (`Ctrl-C`):
|
Probe continuously until interrupted (`Ctrl-C`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
irtt-client netperf-eu.bufferbloat.net:2112 --continuous
|
||||||
|
|
||||||
|
# Recover failed, closed, or silent sessions independently per target
|
||||||
|
irtt-client netperf-eu.bufferbloat.net:2112 --continuous --reconnect \
|
||||||
|
--reconnect-timeout 10s --reconnect-initial-delay 250ms --reconnect-max-delay 10s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reconnection resets statistics for that target; summaries cover its latest
|
||||||
|
session. Add `--no-stats` for measurement events without statistics collection.
|
||||||
|
|
||||||
Watch the same session live in the terminal UI:
|
Watch the same session live in the terminal UI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-tui netperf-eu.bufferbloat.net:2112
|
irtt-tui netperf-eu.bufferbloat.net:2112
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a server with sensible defaults — no `--bind` needed:
|
To run your own server, you can leave out `--bind` and use the defaults:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-server
|
irtt-server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dynamic targets from stdin
|
||||||
|
|
||||||
|
If another application decides which servers to probe, `--targets-stdin`
|
||||||
|
lets it change targets without restarting the client. It works in
|
||||||
|
continuous mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client --continuous --targets-stdin --reconnect --no-stats \
|
||||||
|
--format csv --columns event,target,seq,rd_us,sd_us
|
||||||
|
```
|
||||||
|
|
||||||
|
Send one comma-separated target set per line to stdin, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ams=ams.example:2112,lhr=lhr.example:2112
|
||||||
|
ams=ams.example:2112
|
||||||
|
[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each line gives the complete desired target set. Unchanged targets keep
|
||||||
|
running, removed targets stop, and new or changed targets start. Use `[]`
|
||||||
|
to clear all targets without exiting.
|
||||||
|
|
||||||
|
The CSV includes an `event` column so your script can distinguish
|
||||||
|
`echo_reply` measurements from other events.
|
||||||
|
|
||||||
|
Keep stdin open while controlling the client. Closing it sends EOF and stops the run gracefully.
|
||||||
|
|
||||||
|
See [Dynamic target sets from standard input](docs/irtt-client.md#dynamic-target-sets-from-standard-input)
|
||||||
|
for the full syntax and behavior.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
|
- [`docs/irtt-client.md`](docs/irtt-client.md): target syntax, output
|
||||||
formats and columns, measurement field definitions, finite/continuous
|
formats and columns, measurement field definitions, finite/continuous
|
||||||
memory behavior, kernel timestamps.
|
memory behavior, kernel timestamps.
|
||||||
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
|
- [`docs/irtt-tui.md`](docs/irtt-tui.md): controls, continuous default,
|
||||||
retained history.
|
retained history.
|
||||||
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
|
- [`docs/irtt-server.md`](docs/irtt-server.md): bind defaults and
|
||||||
overrides, wildcard listener behavior, session policy, HMAC.
|
overrides, wildcard listener behavior, session policy, HMAC.
|
||||||
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
|
- [`docs/irtt-rs.md`](docs/irtt-rs.md): the multicall dispatcher, applet
|
||||||
binary names, feature-gated builds.
|
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`.
|
|
||||||
|
|
||||||
Every applet also documents its full option list with `--help`.
|
You can also run any applet with `--help` for its full option list.
|
||||||
|
|
||||||
## Crates
|
## Crates
|
||||||
|
|
||||||
@@ -153,15 +162,13 @@ Every applet also documents its full option list with `--help`.
|
|||||||
| [`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-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)) |
|
| [`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)) |
|
||||||
|
|
||||||
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
|
## Binaries and features
|
||||||
|
|
||||||
| Build | Binaries |
|
| Build | Binaries |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--no-default-features` | `irtt-rs` |
|
| `--no-default-features` | `irtt-rs` |
|
||||||
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
|
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
|
||||||
|
| `--no-default-features --features client,stats` | `irtt-rs`, `irtt-client` (with statistics) |
|
||||||
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
||||||
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
|
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
|
||||||
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
|
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
|
||||||
@@ -174,7 +181,3 @@ cargo test --workspace --locked
|
|||||||
cargo test --workspace --doc --all-features --locked
|
cargo test --workspace --doc --all-features --locked
|
||||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
|
|
||||||
clean-room provenance boundary this project maintains against upstream
|
|
||||||
`irtt`.
|
|
||||||
+16
-14
@@ -1,12 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.7.1"
|
version = "0.10.2"
|
||||||
description = "IRTT-compatible client, TUI, and server applications"
|
description = "IRTT-compatible client, TUI, and server applications"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||||
categories = ["command-line-utilities", "network-programming"]
|
categories = ["command-line-utilities", "network-programming"]
|
||||||
@@ -41,28 +40,31 @@ required-features = ["server"]
|
|||||||
doc = false
|
doc = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["client", "server", "tui"]
|
default = ["client", "server", "tui", "stats"]
|
||||||
client = ["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto"]
|
||||||
server = ["dep:irtt-server", "dep:tokio"]
|
stats = ["dep:irtt-stats"]
|
||||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
server = ["dep:irtt-server", "dep:irtt-proto", "dep:tokio"]
|
||||||
|
tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||||
crossterm = { version = "0.29.0", optional = true }
|
clap-num = "=1.2.0"
|
||||||
|
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||||
|
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||||
|
hex = "0.4.3"
|
||||||
|
humantime = { version = "2.4.0", optional = true }
|
||||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||||
irtt-client = { workspace = true, optional = true }
|
irtt-client = { workspace = true, optional = true }
|
||||||
irtt-proto = { workspace = true, optional = true }
|
irtt-proto = { workspace = true, optional = true }
|
||||||
irtt-server = { workspace = true, optional = true }
|
irtt-server = { workspace = true, optional = true }
|
||||||
irtt-stats = { workspace = true, optional = true }
|
irtt-stats = { workspace = true, optional = true }
|
||||||
ratatui = { version = "0.30.2", optional = true }
|
ratatui = { version = "0.30.2", optional = true }
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"], optional = true }
|
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
irtt-server.workspace = true
|
irtt-server.workspace = true
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
tokio = { version = "1.54.1", default-features = false, features = ["rt", "sync"] }
|
||||||
|
|
||||||
[profile.release]
|
[target.'cfg(unix)'.dev-dependencies]
|
||||||
opt-level = "z"
|
nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] }
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
strip = "symbols"
|
|
||||||
@@ -11,8 +11,13 @@ Installing this crate builds:
|
|||||||
- `irtt-tui` — the interactive terminal UI (feature `tui`).
|
- `irtt-tui` — the interactive terminal UI (feature `tui`).
|
||||||
- `irtt-server` — the dedicated server binary (feature `server`).
|
- `irtt-server` — the dedicated server binary (feature `server`).
|
||||||
|
|
||||||
All three features are enabled by default, so a plain `cargo install irtt-rs`
|
The `client`, `server`, `tui`, and `stats` features are enabled by default, so a
|
||||||
installs everything.
|
plain `cargo install irtt-rs` installs everything. The TUI requires statistics
|
||||||
|
and enables `stats` automatically.
|
||||||
|
|
||||||
|
For a client without statistics, use
|
||||||
|
`cargo install irtt-rs --locked --no-default-features --features client --bin irtt-client`.
|
||||||
|
Use `--features client,stats` for a client-only build with statistics.
|
||||||
|
|
||||||
## This is an application, not a library
|
## This is an application, not a library
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
"-V" | "--version" => Ok(AppletDispatch::Help(crate::version::app())),
|
||||||
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||||
"client" => Ok(AppletDispatch::Run {
|
"client" => Ok(AppletDispatch::Run {
|
||||||
applet: RequestedApplet::Client,
|
applet: RequestedApplet::Client,
|
||||||
@@ -78,6 +79,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
|
|||||||
|
|
||||||
pub fn dispatcher_help() -> String {
|
pub fn dispatcher_help() -> String {
|
||||||
let mut command = Command::new("irtt-rs")
|
let mut command = Command::new("irtt-rs")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("IRTT-compatible multi-applet dispatcher")
|
.about("IRTT-compatible multi-applet dispatcher")
|
||||||
.subcommand(Command::new("client").about(client_applet_about()))
|
.subcommand(Command::new("client").about(client_applet_about()))
|
||||||
.subcommand(Command::new("tui").about(tui_applet_about()))
|
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ use clap::{Parser, ValueEnum};
|
|||||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
#[command(
|
||||||
|
name = "irtt-client",
|
||||||
|
version,
|
||||||
|
about = "Minimal IRTT-compatible stream client"
|
||||||
|
)]
|
||||||
pub struct ClientArgs {
|
pub struct ClientArgs {
|
||||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
value_name = "TARGET",
|
value_name = "TARGET",
|
||||||
num_args = 0..,
|
num_args = 0..,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
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."
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\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>,
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
@@ -27,6 +31,15 @@ pub struct ClientArgs {
|
|||||||
)]
|
)]
|
||||||
pub targets_stdin: bool,
|
pub targets_stdin: bool,
|
||||||
|
|
||||||
|
/// Negotiate parameters and exit without sending measurement probes.
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long,
|
||||||
|
conflicts_with = "targets_stdin",
|
||||||
|
long_help = "Negotiate parameters with each target and exit without sending measurement probes. Duration options still set the requested duration, but do not keep this mode running. Reports negotiated parameters using the selected output format, without a measurement summary."
|
||||||
|
)]
|
||||||
|
pub no_test: bool,
|
||||||
|
|
||||||
/// Managed group pacing for multi-target mode.
|
/// Managed group pacing for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
@@ -38,13 +51,43 @@ pub struct ClientArgs {
|
|||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
|
short = 'd',
|
||||||
default_value = "10s",
|
default_value = "10s",
|
||||||
value_parser = parse_test_duration,
|
value_parser = parse_test_duration,
|
||||||
help = "Test duration; use 0 for continuous mode",
|
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
|
||||||
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."
|
long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted).\n\nWhen statistics support is compiled in and --no-stats is not selected, finite 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,
|
pub duration: Duration,
|
||||||
|
|
||||||
|
/// Run continuously until interrupted (equivalent to --duration 0).
|
||||||
|
#[arg(long, conflicts_with = "duration")]
|
||||||
|
pub continuous: bool,
|
||||||
|
|
||||||
|
/// Disable statistics collection and final summaries; retain measurement event output.
|
||||||
|
/// Accepted even when statistics support is not compiled in.
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_stats: bool,
|
||||||
|
|
||||||
|
/// Retry failed, closed, or silent sessions (requires continuous mode).
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
conflicts_with = "no_test",
|
||||||
|
long_help = "Retry failed initial opens and closed, failed, or silent sessions independently per target. Requires --continuous or --duration 0. Unsuccessful connections use exponential backoff (defaults: 250ms initial, 10s maximum), configurable with --reconnect-initial-delay and --reconnect-max-delay. Use --reconnect-timeout to configure silence detection and --no-stats when only measurement events are needed."
|
||||||
|
)]
|
||||||
|
pub reconnect: bool,
|
||||||
|
|
||||||
|
/// Reconnect after this long without valid replies while sending probes.
|
||||||
|
#[arg(long, default_value = "10s", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
|
||||||
|
pub reconnect_timeout: Duration,
|
||||||
|
|
||||||
|
/// Initial retry delay, restored after a valid reply.
|
||||||
|
#[arg(long, default_value = "250ms", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
|
||||||
|
pub reconnect_initial_delay: Duration,
|
||||||
|
|
||||||
|
/// Maximum retry delay (must be at least twice the initial delay).
|
||||||
|
#[arg(long, default_value = "10s", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
|
||||||
|
pub reconnect_max_delay: Duration,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
pub common: CommonClientArgs,
|
pub common: CommonClientArgs,
|
||||||
|
|
||||||
@@ -52,17 +95,28 @@ pub struct ClientArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_enum,
|
value_enum,
|
||||||
|
short = 'f',
|
||||||
default_value_t = OutputFormat::Table,
|
default_value_t = OutputFormat::Table,
|
||||||
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
||||||
)]
|
)]
|
||||||
pub format: OutputFormat,
|
pub format: OutputFormat,
|
||||||
|
|
||||||
|
/// Filter output by event type.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "EVENTS",
|
||||||
|
value_delimiter = ',',
|
||||||
|
value_enum,
|
||||||
|
long_help = "Filter output by event type. Accepts comma-separated names or repeated options. If omitted, the normal output behavior is unchanged."
|
||||||
|
)]
|
||||||
|
pub events: Vec<EventKind>,
|
||||||
|
|
||||||
/// Comma-separated event row columns, or default/all.
|
/// Comma-separated event row columns, or default/all.
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 'c',
|
short = 'c',
|
||||||
long,
|
long,
|
||||||
value_name = "COLUMNS",
|
value_name = "COLUMNS",
|
||||||
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
|
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows unless explicitly selected with --events. Custom table columns include all event rows. Run --list-columns to see valid names."
|
||||||
)]
|
)]
|
||||||
pub columns: Option<String>,
|
pub columns: Option<String>,
|
||||||
|
|
||||||
@@ -83,7 +137,7 @@ pub struct ClientArgs {
|
|||||||
pub header: HeaderMode,
|
pub header: HeaderMode,
|
||||||
|
|
||||||
/// Include extra fields in table output and final summaries.
|
/// Include extra fields in table output and final summaries.
|
||||||
#[arg(long)]
|
#[arg(short = 'v', long)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,23 +146,44 @@ impl ClientArgs {
|
|||||||
///
|
///
|
||||||
/// Targets are only required here. `--list-columns` returns before this is
|
/// Targets are only required here. `--list-columns` returns before this is
|
||||||
/// called, so listing columns still needs no target.
|
/// called, so listing columns still needs no target.
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
if self.targets_stdin && !self.is_continuous() {
|
if self.reconnect && !self.is_continuous() {
|
||||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
return Err("--reconnect requires --continuous or --duration 0".to_owned());
|
||||||
}
|
}
|
||||||
prepare_managed_run(
|
if self
|
||||||
|
.reconnect_initial_delay
|
||||||
|
.checked_mul(2)
|
||||||
|
.is_none_or(|minimum| self.reconnect_max_delay < minimum)
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"--reconnect-max-delay must be at least twice --reconnect-initial-delay".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.targets_stdin && !self.is_continuous() {
|
||||||
|
return Err("--targets-stdin requires --continuous or --duration 0".to_owned());
|
||||||
|
}
|
||||||
|
let mut setup = prepare_managed_run(
|
||||||
&self.common,
|
&self.common,
|
||||||
self.duration,
|
if self.continuous {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
self.duration
|
||||||
|
},
|
||||||
TargetSelection {
|
TargetSelection {
|
||||||
targets: &self.targets,
|
targets: &self.targets,
|
||||||
pacing: self.pacing,
|
pacing: self.pacing,
|
||||||
stdin_controlled: self.targets_stdin,
|
stdin_controlled: self.targets_stdin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await?;
|
||||||
|
if self.no_test {
|
||||||
|
setup.client.request.run_mode = irtt_client::RunMode::NoTest;
|
||||||
|
}
|
||||||
|
Ok(setup)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
self.duration == Duration::ZERO
|
self.continuous || self.duration == Duration::ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||||
@@ -124,6 +199,29 @@ impl std::ops::Deref for ClientArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
#[value(rename_all = "snake_case")]
|
||||||
|
pub enum EventKind {
|
||||||
|
SessionStarted,
|
||||||
|
NoTestCompleted,
|
||||||
|
SessionClosed,
|
||||||
|
EchoSent,
|
||||||
|
EchoReply,
|
||||||
|
Loss,
|
||||||
|
Duplicate,
|
||||||
|
Late,
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventKind {
|
||||||
|
pub(super) fn name(self) -> String {
|
||||||
|
self.to_possible_value()
|
||||||
|
.expect("all event kinds have a CLI value")
|
||||||
|
.get_name()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
pub enum OutputFormat {
|
pub enum OutputFormat {
|
||||||
/// Readable terminal table output.
|
/// Readable terminal table output.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod args;
|
pub mod args;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod run;
|
pub mod run;
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
|
|
||||||
pub use args::{ClientArgs, HeaderMode, OutputFormat};
|
pub use args::{ClientArgs, HeaderMode, OutputFormat};
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ use irtt_client::{
|
|||||||
ServerTiming, SignedDuration, WarningKind,
|
ServerTiming, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::args::{HeaderMode, OutputFormat};
|
#[cfg(feature = "stats")]
|
||||||
|
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
|
||||||
|
|
||||||
|
use super::args::{EventKind, HeaderMode, OutputFormat};
|
||||||
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct OutputConfig {
|
pub(super) struct OutputConfig {
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
|
events: Vec<EventKind>,
|
||||||
columns: Vec<Column>,
|
columns: Vec<Column>,
|
||||||
header: HeaderMode,
|
header: HeaderMode,
|
||||||
default_table_rows: bool,
|
default_table_rows: bool,
|
||||||
@@ -27,6 +31,7 @@ impl OutputConfig {
|
|||||||
columns: Option<&str>,
|
columns: Option<&str>,
|
||||||
header: HeaderMode,
|
header: HeaderMode,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
|
events: Vec<EventKind>,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let uses_default_columns = columns
|
let uses_default_columns = columns
|
||||||
.map(|columns| columns.trim() == "default")
|
.map(|columns| columns.trim() == "default")
|
||||||
@@ -39,6 +44,7 @@ impl OutputConfig {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
format,
|
format,
|
||||||
|
events,
|
||||||
columns,
|
columns,
|
||||||
header,
|
header,
|
||||||
default_table_rows,
|
default_table_rows,
|
||||||
@@ -46,10 +52,12 @@ impl OutputConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
pub(super) fn prints_summary(&self) -> bool {
|
pub(super) fn prints_summary(&self) -> bool {
|
||||||
self.format.prints_summary()
|
self.format.prints_summary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
pub(super) fn summary_verbose(&self) -> bool {
|
pub(super) fn summary_verbose(&self) -> bool {
|
||||||
self.verbose
|
self.verbose
|
||||||
}
|
}
|
||||||
@@ -94,15 +102,25 @@ impl OutputConfig {
|
|||||||
&self,
|
&self,
|
||||||
event: &ClientEvent,
|
event: &ClientEvent,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
stats: Option<&EventRenderStats>,
|
#[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let row = OutputRow::from_event(event);
|
let row = OutputRow::from_event(event);
|
||||||
if self.default_table_rows && row.is_default_table_hidden() {
|
if !self.events.is_empty() && !self.events.contains(&row.event_kind()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.events.is_empty() && self.default_table_rows && row.is_default_table_hidden() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
let ipdv_ns = row
|
||||||
|
.seq()
|
||||||
|
.and_then(|seq| ipdv_pair(stats, seq))
|
||||||
|
.map(|pair| pair.rtt_ipdv_ns);
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
let ipdv_ns = None;
|
||||||
let context = RenderContext {
|
let context = RenderContext {
|
||||||
stats,
|
ipdv_ns,
|
||||||
target,
|
target,
|
||||||
verbose: self.verbose,
|
verbose: self.verbose,
|
||||||
};
|
};
|
||||||
@@ -133,10 +151,12 @@ impl OutputConfig {
|
|||||||
}
|
}
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(out, "Aliases:").unwrap();
|
writeln!(out, "Aliases:").unwrap();
|
||||||
writeln!(out, " receive_delay, receive_delay_us").unwrap();
|
for column in ALL_COLUMNS {
|
||||||
writeln!(out, " send_delay, send_delay_us").unwrap();
|
let aliases = column.aliases();
|
||||||
writeln!(out, " server_processing").unwrap();
|
if !aliases.is_empty() {
|
||||||
writeln!(out, " server_received_count, server_received_window").unwrap();
|
writeln!(out, " {} ({})", aliases.join(", "), column.name()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(out, "Special column sets:").unwrap();
|
writeln!(out, "Special column sets:").unwrap();
|
||||||
writeln!(
|
writeln!(
|
||||||
@@ -149,45 +169,9 @@ impl OutputConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct EventRenderStats {
|
|
||||||
pub contributed_sample: bool,
|
|
||||||
pub ipdv_pairs: Vec<IpdvPair>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct IpdvPair {
|
|
||||||
pub previous_seq: u32,
|
|
||||||
pub current_seq: u32,
|
|
||||||
pub rtt_ipdv: Duration,
|
|
||||||
pub send_ipdv: Option<Duration>,
|
|
||||||
pub receive_ipdv: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<irtt_stats::EventStatsUpdate> for EventRenderStats {
|
|
||||||
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
contributed_sample: value.contributed_sample,
|
|
||||||
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<irtt_stats::IpdvPairUpdate> for IpdvPair {
|
|
||||||
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
previous_seq: value.previous_seq,
|
|
||||||
current_seq: value.current_seq,
|
|
||||||
rtt_ipdv: value.rtt_ipdv,
|
|
||||||
send_ipdv: value.send_ipdv,
|
|
||||||
receive_ipdv: value.receive_ipdv,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct RenderContext<'a> {
|
struct RenderContext<'a> {
|
||||||
stats: Option<&'a EventRenderStats>,
|
ipdv_ns: Option<i128>,
|
||||||
target: Option<&'a str>,
|
target: Option<&'a str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
}
|
}
|
||||||
@@ -344,17 +328,17 @@ impl OutputRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event_name(&self) -> &'static str {
|
fn event_kind(&self) -> EventKind {
|
||||||
match self {
|
match self {
|
||||||
Self::SessionStarted(_) => "session_started",
|
Self::SessionStarted(_) => EventKind::SessionStarted,
|
||||||
Self::NoTestCompleted(_) => "no_test_completed",
|
Self::NoTestCompleted(_) => EventKind::NoTestCompleted,
|
||||||
Self::SessionClosed { .. } => "session_closed",
|
Self::SessionClosed { .. } => EventKind::SessionClosed,
|
||||||
Self::EchoSent { .. } => "echo_sent",
|
Self::EchoSent { .. } => EventKind::EchoSent,
|
||||||
Self::EchoReply(_) => "echo_reply",
|
Self::EchoReply(_) => EventKind::EchoReply,
|
||||||
Self::Loss { .. } => "loss",
|
Self::Loss { .. } => EventKind::Loss,
|
||||||
Self::Duplicate { .. } => "duplicate",
|
Self::Duplicate { .. } => EventKind::Duplicate,
|
||||||
Self::Late { .. } => "late",
|
Self::Late { .. } => EventKind::Late,
|
||||||
Self::Warning { .. } => "warning",
|
Self::Warning { .. } => EventKind::Warning,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +352,106 @@ impl OutputRow {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seq(&self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent { seq, .. } | Self::Loss { seq, .. } | Self::Duplicate { seq, .. } => {
|
||||||
|
Some(*seq)
|
||||||
|
}
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.seq),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote(&self) -> Option<SocketAddr> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.remote),
|
||||||
|
Self::SessionClosed { remote, .. }
|
||||||
|
| Self::EchoSent { remote, .. }
|
||||||
|
| Self::Duplicate { remote, .. } => Some(*remote),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.remote),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(&self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => row.token,
|
||||||
|
Self::SessionClosed { token, .. } => Some(*token),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent { bytes, .. } | Self::Duplicate { bytes, .. } => Some(*bytes),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.bytes),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(&self, context: RenderContext<'_>) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) => Some(format!(
|
||||||
|
"token={:#x} duration_ns={} interval_ns={} length={}",
|
||||||
|
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
||||||
|
)),
|
||||||
|
Self::NoTestCompleted(row) => Some(format!(
|
||||||
|
"duration_ns={} interval_ns={} length={}",
|
||||||
|
row.duration_ns, row.interval_ns, row.payload_length
|
||||||
|
)),
|
||||||
|
Self::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
||||||
|
Self::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
||||||
|
Self::Warning { message, .. } => Some(message.clone()),
|
||||||
|
Self::Loss { .. } => Some("timeout".to_owned()),
|
||||||
|
Self::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.event_wall),
|
||||||
|
Self::SessionClosed { event_wall, .. } | Self::Warning { event_wall, .. } => {
|
||||||
|
Some(*event_wall)
|
||||||
|
}
|
||||||
|
Self::EchoSent {
|
||||||
|
client_send_wall, ..
|
||||||
|
}
|
||||||
|
| Self::Loss {
|
||||||
|
client_send_wall, ..
|
||||||
|
} => Some(*client_send_wall),
|
||||||
|
Self::Duplicate {
|
||||||
|
client_receive_wall,
|
||||||
|
..
|
||||||
|
} => Some(*client_receive_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_send_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent {
|
||||||
|
client_send_wall, ..
|
||||||
|
}
|
||||||
|
| Self::Loss {
|
||||||
|
client_send_wall, ..
|
||||||
|
} => Some(*client_send_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => reply.client_send_wall,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_receive_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::Duplicate {
|
||||||
|
client_receive_wall,
|
||||||
|
..
|
||||||
|
} => Some(*client_receive_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -415,274 +499,91 @@ struct ReplyRow {
|
|||||||
packet_meta: PacketMeta,
|
packet_meta: PacketMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
// Only schema metadata is generated here; cell_for keeps rendering behavior explicit.
|
||||||
enum Column {
|
macro_rules! columns {
|
||||||
Target,
|
($($variant:ident($name:literal, [$($alias:literal),*], $width:expr, $align:ident, $description:literal);)*) => {
|
||||||
Event,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
Seq,
|
enum Column { $($variant),* }
|
||||||
Remote,
|
|
||||||
Token,
|
const ALL_COLUMNS: &[Column] = &[$(Column::$variant),*];
|
||||||
Rtt,
|
|
||||||
RttUs,
|
impl Column {
|
||||||
RawRttUs,
|
fn name(self) -> &'static str {
|
||||||
EffectiveRttUs,
|
match self { $(Self::$variant => $name),* }
|
||||||
AdjustedRttUs,
|
}
|
||||||
ReceiveDelay,
|
|
||||||
ReceiveDelayUs,
|
fn parse(input: &str) -> Option<Self> {
|
||||||
SendDelay,
|
match input {
|
||||||
SendDelayUs,
|
$($name $(| $alias)* => Some(Self::$variant),)*
|
||||||
Ipdv,
|
_ => None,
|
||||||
IpdvUs,
|
}
|
||||||
ServerProcessing,
|
}
|
||||||
ServerProcessingUs,
|
|
||||||
Bytes,
|
fn aliases(self) -> &'static [&'static str] {
|
||||||
SendCallUs,
|
match self { $(Self::$variant => &[$($alias),*]),* }
|
||||||
TimerErrorUs,
|
}
|
||||||
HighestSeen,
|
|
||||||
ServerReceivedCount,
|
fn description(self) -> &'static str {
|
||||||
ServerReceivedWindow,
|
match self { $(Self::$variant => $description),* }
|
||||||
Dscp,
|
}
|
||||||
Ecn,
|
|
||||||
TrafficClass,
|
fn table_width(self) -> usize {
|
||||||
KernelRxNs,
|
match self { $(Self::$variant => $width),* }
|
||||||
WarningKind,
|
}
|
||||||
Message,
|
|
||||||
EventWallNs,
|
fn align_right(self) -> bool {
|
||||||
ClientSendWallNs,
|
match self { $(Self::$variant => columns!(@align $align)),* }
|
||||||
ClientReceiveWallNs,
|
}
|
||||||
DurationNs,
|
}
|
||||||
IntervalNs,
|
};
|
||||||
PayloadLength,
|
(@align left) => { false };
|
||||||
ServerReceiveWallNs,
|
(@align right) => { true };
|
||||||
ServerReceiveMonoNs,
|
|
||||||
ServerSendWallNs,
|
|
||||||
ServerSendMonoNs,
|
|
||||||
ServerMidpointWallNs,
|
|
||||||
ServerMidpointMonoNs,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_COLUMNS: &[Column] = &[
|
// Variant(canonical name, aliases, table width, alignment, description).
|
||||||
Column::Target,
|
columns! {
|
||||||
Column::Event,
|
Target("target", [], 18, left, "logical CLI target label");
|
||||||
Column::Seq,
|
Event("event", [], 17, left, "event kind");
|
||||||
Column::Remote,
|
Seq("seq", [], 6, right, "probe sequence number");
|
||||||
Column::Token,
|
Remote("remote", [], 21, left, "remote socket address");
|
||||||
Column::Rtt,
|
Token("token", [], 18, left, "session token as hexadecimal");
|
||||||
Column::RttUs,
|
Rtt("rtt", [], 9, right, "human-readable effective RTT");
|
||||||
Column::RawRttUs,
|
RttUs("rtt_us", [], 10, right, "effective RTT in signed microseconds");
|
||||||
Column::EffectiveRttUs,
|
RawRttUs("raw_rtt_us", [], 10, right, "raw client send-to-receive RTT in microseconds");
|
||||||
Column::AdjustedRttUs,
|
EffectiveRttUs("effective_rtt_us", [], 16, right, "effective RTT in signed microseconds");
|
||||||
Column::ReceiveDelay,
|
AdjustedRttUs("adjusted_rtt_us", [], 15, right, "adjusted RTT in signed microseconds");
|
||||||
Column::ReceiveDelayUs,
|
ReceiveDelay("rd", ["receive_delay"], 9, right, "human-readable server-to-client delay");
|
||||||
Column::SendDelay,
|
ReceiveDelayUs("rd_us", ["receive_delay_us"], 10, right, "server-to-client delay in signed microseconds");
|
||||||
Column::SendDelayUs,
|
SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay");
|
||||||
Column::Ipdv,
|
SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds");
|
||||||
Column::IpdvUs,
|
Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples");
|
||||||
Column::ServerProcessing,
|
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in signed microseconds");
|
||||||
Column::ServerProcessingUs,
|
ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time");
|
||||||
Column::Bytes,
|
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
|
||||||
Column::SendCallUs,
|
Bytes("bytes", [], 10, right, "packet bytes for packet events");
|
||||||
Column::TimerErrorUs,
|
SendCallUs("send_call_us", [], 12, right, "send system call duration in microseconds");
|
||||||
Column::HighestSeen,
|
TimerErrorUs("timer_error_us", [], 14, right, "scheduled-vs-actual send timer error in microseconds");
|
||||||
Column::ServerReceivedCount,
|
HighestSeen("highest_seen", [], 15, right, "highest sequence seen when a late reply arrived");
|
||||||
Column::ServerReceivedWindow,
|
ServerReceivedCount("server_received", ["server_received_count"], 15, right, "server-reported received packet count");
|
||||||
Column::Dscp,
|
ServerReceivedWindow("server_window", ["server_received_window"], 13, left, "server-reported received window as hexadecimal");
|
||||||
Column::Ecn,
|
Dscp("dscp", [], 4, right, "received packet DSCP codepoint");
|
||||||
Column::TrafficClass,
|
Ecn("ecn", [], 4, right, "received packet ECN bits");
|
||||||
Column::KernelRxNs,
|
TrafficClass("traffic_class", [], 13, right, "received packet traffic class byte");
|
||||||
Column::WarningKind,
|
KernelRxNs("kernel_rx_ns", [], 12, right, "kernel receive timestamp as Unix nanoseconds");
|
||||||
Column::Message,
|
WarningKind("warning_kind", [], 28, left, "warning classifier");
|
||||||
Column::EventWallNs,
|
Message("message", [], 24, left, "warning or lifecycle message");
|
||||||
Column::ClientSendWallNs,
|
EventWallNs("event_wall_ns", [], 13, right, "event wall timestamp as Unix nanoseconds");
|
||||||
Column::ClientReceiveWallNs,
|
ClientSendWallNs("client_send_wall_ns", [], 19, right, "client send wall timestamp as Unix nanoseconds");
|
||||||
Column::DurationNs,
|
ClientReceiveWallNs("client_receive_wall_ns", [], 22, right, "client receive wall timestamp as Unix nanoseconds");
|
||||||
Column::IntervalNs,
|
DurationNs("duration_ns", [], 11, right, "negotiated test duration in nanoseconds");
|
||||||
Column::PayloadLength,
|
IntervalNs("interval_ns", [], 11, right, "negotiated probe interval in nanoseconds");
|
||||||
Column::ServerReceiveWallNs,
|
PayloadLength("payload_length", [], 14, right, "negotiated payload length");
|
||||||
Column::ServerReceiveMonoNs,
|
ServerReceiveWallNs("server_receive_wall_ns", [], 22, right, "server receive wall timestamp in nanoseconds");
|
||||||
Column::ServerSendWallNs,
|
ServerReceiveMonoNs("server_receive_mono_ns", [], 22, right, "server receive monotonic timestamp in nanoseconds");
|
||||||
Column::ServerSendMonoNs,
|
ServerSendWallNs("server_send_wall_ns", [], 19, right, "server send wall timestamp in nanoseconds");
|
||||||
Column::ServerMidpointWallNs,
|
ServerSendMonoNs("server_send_mono_ns", [], 19, right, "server send monotonic timestamp in nanoseconds");
|
||||||
Column::ServerMidpointMonoNs,
|
ServerMidpointWallNs("server_midpoint_wall_ns", [], 23, right, "server midpoint wall timestamp in nanoseconds");
|
||||||
];
|
ServerMidpointMonoNs("server_midpoint_mono_ns", [], 23, right, "server midpoint monotonic timestamp in nanoseconds");
|
||||||
|
|
||||||
impl Column {
|
|
||||||
fn name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Target => "target",
|
|
||||||
Self::Event => "event",
|
|
||||||
Self::Seq => "seq",
|
|
||||||
Self::Remote => "remote",
|
|
||||||
Self::Token => "token",
|
|
||||||
Self::Rtt => "rtt",
|
|
||||||
Self::RttUs => "rtt_us",
|
|
||||||
Self::RawRttUs => "raw_rtt_us",
|
|
||||||
Self::EffectiveRttUs => "effective_rtt_us",
|
|
||||||
Self::AdjustedRttUs => "adjusted_rtt_us",
|
|
||||||
Self::ReceiveDelay => "rd",
|
|
||||||
Self::ReceiveDelayUs => "rd_us",
|
|
||||||
Self::SendDelay => "sd",
|
|
||||||
Self::SendDelayUs => "sd_us",
|
|
||||||
Self::Ipdv => "ipdv",
|
|
||||||
Self::IpdvUs => "ipdv_us",
|
|
||||||
Self::ServerProcessing => "proc",
|
|
||||||
Self::ServerProcessingUs => "server_processing_us",
|
|
||||||
Self::Bytes => "bytes",
|
|
||||||
Self::SendCallUs => "send_call_us",
|
|
||||||
Self::TimerErrorUs => "timer_error_us",
|
|
||||||
Self::HighestSeen => "highest_seen",
|
|
||||||
Self::ServerReceivedCount => "server_received",
|
|
||||||
Self::ServerReceivedWindow => "server_window",
|
|
||||||
Self::Dscp => "dscp",
|
|
||||||
Self::Ecn => "ecn",
|
|
||||||
Self::TrafficClass => "traffic_class",
|
|
||||||
Self::KernelRxNs => "kernel_rx_ns",
|
|
||||||
Self::WarningKind => "warning_kind",
|
|
||||||
Self::Message => "message",
|
|
||||||
Self::EventWallNs => "event_wall_ns",
|
|
||||||
Self::ClientSendWallNs => "client_send_wall_ns",
|
|
||||||
Self::ClientReceiveWallNs => "client_receive_wall_ns",
|
|
||||||
Self::DurationNs => "duration_ns",
|
|
||||||
Self::IntervalNs => "interval_ns",
|
|
||||||
Self::PayloadLength => "payload_length",
|
|
||||||
Self::ServerReceiveWallNs => "server_receive_wall_ns",
|
|
||||||
Self::ServerReceiveMonoNs => "server_receive_mono_ns",
|
|
||||||
Self::ServerSendWallNs => "server_send_wall_ns",
|
|
||||||
Self::ServerSendMonoNs => "server_send_mono_ns",
|
|
||||||
Self::ServerMidpointWallNs => "server_midpoint_wall_ns",
|
|
||||||
Self::ServerMidpointMonoNs => "server_midpoint_mono_ns",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse(input: &str) -> Option<Self> {
|
|
||||||
Some(match input {
|
|
||||||
"target" => Self::Target,
|
|
||||||
"event" => Self::Event,
|
|
||||||
"seq" => Self::Seq,
|
|
||||||
"remote" => Self::Remote,
|
|
||||||
"token" => Self::Token,
|
|
||||||
"rtt" => Self::Rtt,
|
|
||||||
"rtt_us" => Self::RttUs,
|
|
||||||
"raw_rtt_us" => Self::RawRttUs,
|
|
||||||
"effective_rtt_us" => Self::EffectiveRttUs,
|
|
||||||
"adjusted_rtt_us" => Self::AdjustedRttUs,
|
|
||||||
"rd" | "receive_delay" => Self::ReceiveDelay,
|
|
||||||
"rd_us" | "receive_delay_us" => Self::ReceiveDelayUs,
|
|
||||||
"sd" | "send_delay" => Self::SendDelay,
|
|
||||||
"sd_us" | "send_delay_us" => Self::SendDelayUs,
|
|
||||||
"ipdv" => Self::Ipdv,
|
|
||||||
"ipdv_us" => Self::IpdvUs,
|
|
||||||
"proc" | "server_processing" => Self::ServerProcessing,
|
|
||||||
"server_processing_us" => Self::ServerProcessingUs,
|
|
||||||
"bytes" => Self::Bytes,
|
|
||||||
"send_call_us" => Self::SendCallUs,
|
|
||||||
"timer_error_us" => Self::TimerErrorUs,
|
|
||||||
"highest_seen" => Self::HighestSeen,
|
|
||||||
"server_received" | "server_received_count" => Self::ServerReceivedCount,
|
|
||||||
"server_window" | "server_received_window" => Self::ServerReceivedWindow,
|
|
||||||
"dscp" => Self::Dscp,
|
|
||||||
"ecn" => Self::Ecn,
|
|
||||||
"traffic_class" => Self::TrafficClass,
|
|
||||||
"kernel_rx_ns" => Self::KernelRxNs,
|
|
||||||
"warning_kind" => Self::WarningKind,
|
|
||||||
"message" => Self::Message,
|
|
||||||
"event_wall_ns" => Self::EventWallNs,
|
|
||||||
"client_send_wall_ns" => Self::ClientSendWallNs,
|
|
||||||
"client_receive_wall_ns" => Self::ClientReceiveWallNs,
|
|
||||||
"duration_ns" => Self::DurationNs,
|
|
||||||
"interval_ns" => Self::IntervalNs,
|
|
||||||
"payload_length" => Self::PayloadLength,
|
|
||||||
"server_receive_wall_ns" => Self::ServerReceiveWallNs,
|
|
||||||
"server_receive_mono_ns" => Self::ServerReceiveMonoNs,
|
|
||||||
"server_send_wall_ns" => Self::ServerSendWallNs,
|
|
||||||
"server_send_mono_ns" => Self::ServerSendMonoNs,
|
|
||||||
"server_midpoint_wall_ns" => Self::ServerMidpointWallNs,
|
|
||||||
"server_midpoint_mono_ns" => Self::ServerMidpointMonoNs,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Target => "logical CLI target label",
|
|
||||||
Self::Event => "event kind",
|
|
||||||
Self::Seq => "probe sequence number",
|
|
||||||
Self::Remote => "remote socket address",
|
|
||||||
Self::Token => "session token as hexadecimal",
|
|
||||||
Self::Rtt => "human-readable effective RTT",
|
|
||||||
Self::RttUs => "effective RTT in signed microseconds",
|
|
||||||
Self::RawRttUs => "raw client send-to-receive RTT in microseconds",
|
|
||||||
Self::EffectiveRttUs => "effective RTT in signed microseconds",
|
|
||||||
Self::AdjustedRttUs => "adjusted RTT in signed microseconds",
|
|
||||||
Self::ReceiveDelay => "human-readable server-to-client delay",
|
|
||||||
Self::ReceiveDelayUs => "server-to-client delay in signed microseconds",
|
|
||||||
Self::SendDelay => "human-readable client-to-server delay",
|
|
||||||
Self::SendDelayUs => "client-to-server delay in signed microseconds",
|
|
||||||
Self::Ipdv => "human-readable round-trip IPDV for adjacent samples",
|
|
||||||
Self::IpdvUs => "round-trip IPDV in microseconds",
|
|
||||||
Self::ServerProcessing => "human-readable server processing time",
|
|
||||||
Self::ServerProcessingUs => "server processing time in microseconds",
|
|
||||||
Self::Bytes => "packet bytes for packet events",
|
|
||||||
Self::SendCallUs => "send system call duration in microseconds",
|
|
||||||
Self::TimerErrorUs => "scheduled-vs-actual send timer error in microseconds",
|
|
||||||
Self::HighestSeen => "highest sequence seen when a late reply arrived",
|
|
||||||
Self::ServerReceivedCount => "server-reported received packet count",
|
|
||||||
Self::ServerReceivedWindow => "server-reported received window as hexadecimal",
|
|
||||||
Self::Dscp => "received packet DSCP codepoint",
|
|
||||||
Self::Ecn => "received packet ECN bits",
|
|
||||||
Self::TrafficClass => "received packet traffic class byte",
|
|
||||||
Self::KernelRxNs => "kernel receive timestamp as Unix nanoseconds",
|
|
||||||
Self::WarningKind => "warning classifier",
|
|
||||||
Self::Message => "warning or lifecycle message",
|
|
||||||
Self::EventWallNs => "event wall timestamp as Unix nanoseconds",
|
|
||||||
Self::ClientSendWallNs => "client send wall timestamp as Unix nanoseconds",
|
|
||||||
Self::ClientReceiveWallNs => "client receive wall timestamp as Unix nanoseconds",
|
|
||||||
Self::DurationNs => "negotiated test duration in nanoseconds",
|
|
||||||
Self::IntervalNs => "negotiated probe interval in nanoseconds",
|
|
||||||
Self::PayloadLength => "negotiated payload length",
|
|
||||||
Self::ServerReceiveWallNs => "server receive wall timestamp in nanoseconds",
|
|
||||||
Self::ServerReceiveMonoNs => "server receive monotonic timestamp in nanoseconds",
|
|
||||||
Self::ServerSendWallNs => "server send wall timestamp in nanoseconds",
|
|
||||||
Self::ServerSendMonoNs => "server send monotonic timestamp in nanoseconds",
|
|
||||||
Self::ServerMidpointWallNs => "server midpoint wall timestamp in nanoseconds",
|
|
||||||
Self::ServerMidpointMonoNs => "server midpoint monotonic timestamp in nanoseconds",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn table_width(self) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Target => 18,
|
|
||||||
Self::Event => 17,
|
|
||||||
Self::Seq => 6,
|
|
||||||
Self::Remote => 21,
|
|
||||||
Self::Token => 18,
|
|
||||||
Self::Rtt
|
|
||||||
| Self::ReceiveDelay
|
|
||||||
| Self::SendDelay
|
|
||||||
| Self::Ipdv
|
|
||||||
| Self::ServerProcessing => 9,
|
|
||||||
Self::Message => 24,
|
|
||||||
Self::WarningKind => 28,
|
|
||||||
Self::ServerReceivedWindow => 13,
|
|
||||||
Self::ServerReceivedCount | Self::HighestSeen => 15,
|
|
||||||
Self::Dscp | Self::Ecn => 4,
|
|
||||||
Self::TrafficClass => 13,
|
|
||||||
_ => self.name().len().max(10),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn align_right(self) -> bool {
|
|
||||||
!matches!(
|
|
||||||
self,
|
|
||||||
Self::Target
|
|
||||||
| Self::Event
|
|
||||||
| Self::Remote
|
|
||||||
| Self::Token
|
|
||||||
| Self::Message
|
|
||||||
| Self::WarningKind
|
|
||||||
| Self::ServerReceivedWindow
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -889,10 +790,12 @@ fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<
|
|||||||
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
|
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
|
||||||
match column {
|
match column {
|
||||||
Column::Target => context.target.map(CellValue::text),
|
Column::Target => context.target.map(CellValue::text),
|
||||||
Column::Event => Some(CellValue::text(row.event_name())),
|
Column::Event => Some(CellValue::text(row.event_kind().name())),
|
||||||
Column::Seq => row_seq(row).map(|seq| CellValue::Unsigned(u128::from(seq))),
|
Column::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
|
||||||
Column::Remote => row_remote(row).map(|remote| CellValue::text(remote.to_string())),
|
Column::Remote => row
|
||||||
Column::Token => row_token(row).map(|token| CellValue::Hex(u128::from(token))),
|
.remote()
|
||||||
|
.map(|remote| CellValue::text(remote.to_string())),
|
||||||
|
Column::Token => row.token().map(|token| CellValue::Hex(u128::from(token))),
|
||||||
Column::Rtt => row.reply().and_then(|reply| {
|
Column::Rtt => row.reply().and_then(|reply| {
|
||||||
reply
|
reply
|
||||||
.rtt
|
.rtt
|
||||||
@@ -930,12 +833,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
.reply()
|
.reply()
|
||||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
||||||
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
||||||
Column::Ipdv => row_seq(row)
|
Column::Ipdv => context.ipdv_ns.map(|value| {
|
||||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
CellValue::text(format_signed_duration(SignedDuration::from_nanos(value)))
|
||||||
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
}),
|
||||||
Column::IpdvUs => row_seq(row)
|
Column::IpdvUs => context
|
||||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
.ipdv_ns
|
||||||
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
.map(|value| CellValue::Integer(value / 1_000)),
|
||||||
Column::ServerProcessing => row
|
Column::ServerProcessing => row
|
||||||
.reply()
|
.reply()
|
||||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||||
@@ -944,7 +847,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
.reply()
|
.reply()
|
||||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||||
.map(|value| CellValue::Unsigned(duration_us(value))),
|
.map(|value| CellValue::Unsigned(duration_us(value))),
|
||||||
Column::Bytes => row_bytes(row).map(|bytes| CellValue::Unsigned(bytes as u128)),
|
Column::Bytes => row.bytes().map(|bytes| CellValue::Unsigned(bytes as u128)),
|
||||||
Column::SendCallUs => match row {
|
Column::SendCallUs => match row {
|
||||||
OutputRow::EchoSent { send_call, .. } => {
|
OutputRow::EchoSent { send_call, .. } => {
|
||||||
Some(CellValue::Unsigned(duration_us(*send_call)))
|
Some(CellValue::Unsigned(duration_us(*send_call)))
|
||||||
@@ -992,14 +895,17 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
Column::Message => row_message(row, context).map(CellValue::text),
|
Column::Message => row.message(context).map(CellValue::text),
|
||||||
Column::EventWallNs => row_event_wall(row)
|
Column::EventWallNs => row
|
||||||
|
.event_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::ClientSendWallNs => row_client_send_wall(row)
|
Column::ClientSendWallNs => row
|
||||||
|
.client_send_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::ClientReceiveWallNs => row_client_receive_wall(row)
|
Column::ClientReceiveWallNs => row
|
||||||
|
.client_receive_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::DurationNs => match row {
|
Column::DurationNs => match row {
|
||||||
@@ -1029,110 +935,6 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_seq(row: &OutputRow) -> Option<u32> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent { seq, .. }
|
|
||||||
| OutputRow::Loss { seq, .. }
|
|
||||||
| OutputRow::Duplicate { seq, .. } => Some(*seq),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.seq),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_remote(row: &OutputRow) -> Option<SocketAddr> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.remote),
|
|
||||||
OutputRow::SessionClosed { remote, .. }
|
|
||||||
| OutputRow::EchoSent { remote, .. }
|
|
||||||
| OutputRow::Duplicate { remote, .. } => Some(*remote),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.remote),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_token(row: &OutputRow) -> Option<u64> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => row.token,
|
|
||||||
OutputRow::SessionClosed { token, .. } => Some(*token),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_bytes(row: &OutputRow) -> Option<usize> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent { bytes, .. } | OutputRow::Duplicate { bytes, .. } => Some(*bytes),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.bytes),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_message(row: &OutputRow, context: RenderContext<'_>) -> Option<String> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) => Some(format!(
|
|
||||||
"token={:#x} duration_ns={} interval_ns={} length={}",
|
|
||||||
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
|
||||||
)),
|
|
||||||
OutputRow::NoTestCompleted(row) => Some(format!(
|
|
||||||
"duration_ns={} interval_ns={} length={}",
|
|
||||||
row.duration_ns, row.interval_ns, row.payload_length
|
|
||||||
)),
|
|
||||||
OutputRow::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
|
||||||
OutputRow::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
|
||||||
OutputRow::Warning { message, .. } => Some(message.clone()),
|
|
||||||
OutputRow::Loss { .. } => Some("timeout".to_owned()),
|
|
||||||
OutputRow::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_event_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.event_wall),
|
|
||||||
OutputRow::SessionClosed { event_wall, .. } | OutputRow::Warning { event_wall, .. } => {
|
|
||||||
Some(*event_wall)
|
|
||||||
}
|
|
||||||
OutputRow::EchoSent {
|
|
||||||
client_send_wall, ..
|
|
||||||
}
|
|
||||||
| OutputRow::Loss {
|
|
||||||
client_send_wall, ..
|
|
||||||
} => Some(*client_send_wall),
|
|
||||||
OutputRow::Duplicate {
|
|
||||||
client_receive_wall,
|
|
||||||
..
|
|
||||||
} => Some(*client_receive_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
||||||
Some(reply.client_receive_wall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_client_send_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent {
|
|
||||||
client_send_wall, ..
|
|
||||||
}
|
|
||||||
| OutputRow::Loss {
|
|
||||||
client_send_wall, ..
|
|
||||||
} => Some(*client_send_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => reply.client_send_wall,
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_client_receive_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::Duplicate {
|
|
||||||
client_receive_wall,
|
|
||||||
..
|
|
||||||
} => Some(*client_receive_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
||||||
Some(reply.client_receive_wall)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn server_timing_i64(
|
fn server_timing_i64(
|
||||||
row: &OutputRow,
|
row: &OutputRow,
|
||||||
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
||||||
@@ -1143,7 +945,8 @@ fn server_timing_i64(
|
|||||||
.map(|value| CellValue::Integer(i128::from(value)))
|
.map(|value| CellValue::Integer(i128::from(value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipdv_pair(stats: Option<&EventRenderStats>, seq: u32) -> Option<&IpdvPair> {
|
#[cfg(feature = "stats")]
|
||||||
|
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
|
||||||
let stats = stats?;
|
let stats = stats?;
|
||||||
stats
|
stats
|
||||||
.ipdv_pairs
|
.ipdv_pairs
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -57,12 +57,13 @@ pub(crate) fn format_ns_i128(value: i128) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||||
|
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
|
||||||
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
||||||
value.map(format_ns_i128).unwrap_or_else(absent)
|
value.map(format_ns_i128).unwrap_or_else(absent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||||
#[cfg(feature = "client")]
|
#[cfg(all(feature = "client", feature = "stats"))]
|
||||||
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
|
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
|
||||||
value.map(format_ns_f64).unwrap_or_else(absent)
|
value.map(format_ns_f64).unwrap_or_else(absent)
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@ pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
|||||||
|
|
||||||
/// Format a percentage with two decimals; non-finite values render as
|
/// Format a percentage with two decimals; non-finite values render as
|
||||||
/// [`ABSENT`].
|
/// [`ABSENT`].
|
||||||
|
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
|
||||||
pub(crate) fn format_percent(value: f64) -> String {
|
pub(crate) fn format_percent(value: f64) -> String {
|
||||||
if value.is_finite() {
|
if value.is_finite() {
|
||||||
format!("{value:.2}%")
|
format!("{value:.2}%")
|
||||||
@@ -112,6 +114,7 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
|||||||
value.map(format_count).unwrap_or_else(absent)
|
value.map(format_count).unwrap_or_else(absent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
|
||||||
fn absent() -> String {
|
fn absent() -> String {
|
||||||
ABSENT.to_owned()
|
ABSENT.to_owned()
|
||||||
}
|
}
|
||||||
@@ -12,9 +12,10 @@ const DEFAULT_PORT: u16 = 2112;
|
|||||||
// Every policy option is optional. An omitted option leaves the corresponding
|
// Every policy option is optional. An omitted option leaves the corresponding
|
||||||
// `ServerConfig` default in place rather than restating it here, so library and
|
// `ServerConfig` default in place rather than restating it here, so library and
|
||||||
// CLI defaults cannot drift apart.
|
// CLI defaults cannot drift apart.
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Clone, Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "irtt-server",
|
name = "irtt-server",
|
||||||
|
version,
|
||||||
about = "Minimal IRTT-compatible UDP server",
|
about = "Minimal IRTT-compatible UDP server",
|
||||||
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."
|
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."
|
||||||
)]
|
)]
|
||||||
@@ -28,13 +29,14 @@ pub struct ServerArgs {
|
|||||||
)]
|
)]
|
||||||
pub bind: Vec<SocketAddr>,
|
pub bind: Vec<SocketAddr>,
|
||||||
|
|
||||||
|
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||||
/// HMAC key; every request must then carry a valid MAC.
|
/// HMAC key; every request must then carry a valid MAC.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "KEY",
|
value_name = "KEY",
|
||||||
value_parser = parse_hmac_key,
|
|
||||||
env = "IRTT_SERVER_HMAC",
|
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."
|
hide_env_values = true,
|
||||||
|
long_help = "HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes (digits may use either case). Empty keys and invalid hex are rejected.\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>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
@@ -126,6 +128,23 @@ pub struct ServerArgs {
|
|||||||
pub no_dscp: bool,
|
pub no_dscp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ServerArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ServerArgs")
|
||||||
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
|
.field("bind", &self.bind)
|
||||||
|
.field("max_sessions", &self.max_sessions)
|
||||||
|
.field("max_packet_length", &self.max_packet_length)
|
||||||
|
.field("min_interval", &self.min_interval)
|
||||||
|
.field("burst", &self.burst)
|
||||||
|
.field("idle_timeout", &self.idle_timeout)
|
||||||
|
.field("max_duration", &self.max_duration)
|
||||||
|
.field("timestamp_allowance", &self.timestamp_allowance)
|
||||||
|
.field("no_dscp", &self.no_dscp)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The command-line spelling of [`TimestampAllowance`].
|
/// The command-line spelling of [`TimestampAllowance`].
|
||||||
///
|
///
|
||||||
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
||||||
@@ -166,11 +185,10 @@ impl ServerArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the server configuration this invocation asks for.
|
/// Builds the server configuration this invocation asks for.
|
||||||
#[must_use]
|
pub fn server_config(&self) -> Result<ServerConfig, String> {
|
||||||
pub fn server_config(&self) -> ServerConfig {
|
|
||||||
let mut config = ServerConfig::default();
|
let mut config = ServerConfig::default();
|
||||||
if let Some(key) = self.hmac.as_ref() {
|
if let Some(key) = self.hmac.as_ref() {
|
||||||
config = config.with_hmac_key(key.as_bytes());
|
config = config.with_hmac_key(crate::shared::decode_hmac_key(key)?);
|
||||||
}
|
}
|
||||||
if let Some(value) = self.max_sessions {
|
if let Some(value) = self.max_sessions {
|
||||||
config = config.with_max_sessions(value);
|
config = config.with_max_sessions(value);
|
||||||
@@ -198,19 +216,10 @@ impl ServerArgs {
|
|||||||
if self.no_dscp {
|
if self.no_dscp {
|
||||||
config = config.with_dscp_allowed(false);
|
config = config.with_dscp_allowed(false);
|
||||||
}
|
}
|
||||||
config
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// Parses a duration whose zero is meaningful server policy.
|
||||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||||
if input == "0" {
|
if input == "0" {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async fn serve(
|
|||||||
args: ServerArgs,
|
args: ServerArgs,
|
||||||
shutdown_requested: &AtomicBool,
|
shutdown_requested: &AtomicBool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = args.server_config();
|
let config = args.server_config()?;
|
||||||
let binds = args.resolve_binds();
|
let binds = args.resolve_binds();
|
||||||
let is_implicit_default = args.bind.is_empty();
|
let is_implicit_default = args.bind.is_empty();
|
||||||
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ use crate::shared::client::{
|
|||||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
#[command(
|
||||||
|
name = "irtt-tui",
|
||||||
|
version,
|
||||||
|
about = "Minimal IRTT-compatible TUI client"
|
||||||
|
)]
|
||||||
pub struct TuiArgs {
|
pub struct TuiArgs {
|
||||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -18,7 +22,7 @@ pub struct TuiArgs {
|
|||||||
num_args = 1..,
|
num_args = 1..,
|
||||||
required = true,
|
required = true,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
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"
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\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>,
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
@@ -33,13 +37,18 @@ pub struct TuiArgs {
|
|||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
|
short = 'd',
|
||||||
default_value = "0",
|
default_value = "0",
|
||||||
value_parser = parse_test_duration,
|
value_parser = parse_test_duration,
|
||||||
help = "Test duration; use 0 for continuous mode",
|
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
|
||||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted). The TUI defaults to continuous mode."
|
||||||
)]
|
)]
|
||||||
pub duration: Duration,
|
pub duration: Duration,
|
||||||
|
|
||||||
|
/// Run continuously until interrupted (equivalent to --duration 0).
|
||||||
|
#[arg(long, conflicts_with = "duration")]
|
||||||
|
pub continuous: bool,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
pub common: CommonClientArgs,
|
pub common: CommonClientArgs,
|
||||||
}
|
}
|
||||||
@@ -49,20 +58,25 @@ impl TuiArgs {
|
|||||||
///
|
///
|
||||||
/// The TUI always requires a target: the parser rejects an empty target
|
/// The TUI always requires a target: the parser rejects an empty target
|
||||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
/// list, and this rejects a target set that cannot be labelled uniquely.
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
prepare_managed_run(
|
prepare_managed_run(
|
||||||
&self.common,
|
&self.common,
|
||||||
self.duration,
|
if self.continuous {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
self.duration
|
||||||
|
},
|
||||||
TargetSelection {
|
TargetSelection {
|
||||||
targets: &self.targets,
|
targets: &self.targets,
|
||||||
pacing: self.pacing,
|
pacing: self.pacing,
|
||||||
stdin_controlled: false,
|
stdin_controlled: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
self.duration == Duration::ZERO
|
self.continuous || self.duration == Duration::ZERO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+216
-294
@@ -1,356 +1,278 @@
|
|||||||
use std::{
|
use std::{future::poll_fn, io, time::Duration};
|
||||||
collections::HashSet,
|
|
||||||
io,
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd::tui::args::TuiArgs,
|
cmd::tui::args::TuiArgs,
|
||||||
shared::client::{
|
shared::client::{
|
||||||
is_shutdown_requested,
|
session::{drain_final_events, request_managed_stop_once},
|
||||||
session::{
|
worker::ManagedWorker,
|
||||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
|
||||||
request_managed_stop_once, ManagedDrainState,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::{
|
||||||
use irtt_client::managed::{
|
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
|
||||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
terminal,
|
||||||
ManagedEventTryRecvError, TargetInstance,
|
};
|
||||||
|
use futures_core::Stream;
|
||||||
|
use irtt_client::{
|
||||||
|
managed::{
|
||||||
|
ManagedClient, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy,
|
||||||
|
ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetLifecycle,
|
||||||
|
},
|
||||||
|
ClientEvent,
|
||||||
|
};
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use tokio::{
|
||||||
|
sync::{broadcast::error::RecvError, watch},
|
||||||
|
time::{sleep, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
const RETRY_DELAY: Duration = Duration::from_millis(1500);
|
||||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
||||||
const INPUT_EVENT_WORK_BUDGET: usize = 128;
|
|
||||||
|
|
||||||
pub fn run_tui(
|
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
pub async fn run_tui(
|
||||||
args: TuiArgs,
|
args: TuiArgs,
|
||||||
shutdown_requested: &AtomicBool,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let setup = args
|
let setup = tokio::select! {
|
||||||
.prepare()
|
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||||
|
};
|
||||||
let continuous = args.is_continuous();
|
let continuous = args.is_continuous();
|
||||||
|
if *shutdown.borrow() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut terminal = TuiTerminal::enter()?;
|
let mut terminal = TuiTerminal::enter()?;
|
||||||
let mut state = TuiState::with_target_labels(
|
let mut state = TuiState::with_target_labels(
|
||||||
TuiConfig::from_args(&args, &setup),
|
TuiConfig::from_args(&args, &setup),
|
||||||
setup.targets.iter().map(|target| target.label.clone()),
|
setup.targets.iter().map(|target| target.label.clone()),
|
||||||
);
|
);
|
||||||
let mut next_render = Instant::now();
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
state.set_status(TuiStatus::Opening);
|
state.set_status(TuiStatus::Opening);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
|
let desired_targets = setup.managed_targets();
|
||||||
setup.managed_config(),
|
let mut config = setup.managed_config();
|
||||||
setup.managed_targets(),
|
if continuous {
|
||||||
) {
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
}
|
||||||
|
let (task, handle) = match ManagedClient::task(config, desired_targets.clone()) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
state.set_error(error.to_string());
|
state.set_error(error.to_string());
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
return Err(Box::new(error));
|
return Err(Box::new(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let handle = owner.handle();
|
// Both subscriptions exist before the measurement task can be polled.
|
||||||
|
let mut events = handle.subscribe()?;
|
||||||
|
let mut status = handle.subscribe_status();
|
||||||
|
let worker = match ManagedWorker::start(task, handle.clone()) {
|
||||||
|
Ok(worker) => worker,
|
||||||
|
Err(error) => {
|
||||||
|
state.set_run_error(error.to_string());
|
||||||
|
terminal.draw(&state)?;
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let completion = worker.join();
|
||||||
|
tokio::pin!(completion);
|
||||||
|
// Input and rendering stay on the frontend executor. EventStream's drop
|
||||||
|
// wakes its reader; no terminal task can outlive the terminal guard.
|
||||||
|
let mut input = EventStream::new();
|
||||||
|
let render = sleep(RENDER_INTERVAL);
|
||||||
|
tokio::pin!(render);
|
||||||
let mut interrupted = false;
|
let mut interrupted = false;
|
||||||
let mut stop_requested = false;
|
let mut stop_requested = false;
|
||||||
let mut terminal_targets = HashSet::new();
|
let retry = sleep(RETRY_DELAY);
|
||||||
let mut dropped_events = 0;
|
tokio::pin!(retry);
|
||||||
let mut subscription_closed = false;
|
let mut retry_armed = false;
|
||||||
loop {
|
let mut update_receipt = None;
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
let mut dropped_events = 0_u64;
|
||||||
interrupted = true;
|
let mut events_closed = false;
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
let mut status_closed = false;
|
||||||
drop(handle.stop());
|
let outcome = loop {
|
||||||
|
let mut force_render = false;
|
||||||
|
// Default select fairness lets input, status and rendering progress
|
||||||
|
// even when measurement events are continuously ready.
|
||||||
|
tokio::select! {
|
||||||
|
outcome = &mut completion => break outcome,
|
||||||
|
_ = shutdown.changed(), if !interrupted => {
|
||||||
|
interrupted = true;
|
||||||
|
state.set_status(TuiStatus::Interrupted);
|
||||||
|
force_render = true;
|
||||||
}
|
}
|
||||||
}
|
changed = status.changed(), if !status_closed => {
|
||||||
if handle_input(&mut state, shutdown_requested)? {
|
status_closed = changed.is_err();
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
state.process_managed_status(&status.borrow_and_update());
|
||||||
}
|
force_render = true;
|
||||||
if state.quit_requested {
|
|
||||||
interrupted = true;
|
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
|
||||||
drop(handle.stop());
|
|
||||||
}
|
}
|
||||||
|
event = events.recv(), if !events_closed => match event {
|
||||||
|
Ok(event) => force_render = process_tui_event(
|
||||||
|
event, &mut state,
|
||||||
|
),
|
||||||
|
Err(RecvError::Lagged(count)) => {
|
||||||
|
dropped_events = dropped_events.saturating_add(count);
|
||||||
|
state.mark_dropped_managed_events(dropped_events);
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
Err(RecvError::Closed) => events_closed = true,
|
||||||
|
},
|
||||||
|
event = poll_fn(|cx| std::pin::Pin::new(&mut input).poll_next(cx)) => {
|
||||||
|
let event = event.ok_or_else(|| io::Error::new(
|
||||||
|
io::ErrorKind::UnexpectedEof, "terminal input closed",
|
||||||
|
))??;
|
||||||
|
match event {
|
||||||
|
Event::Resize(_, _) => force_render = true,
|
||||||
|
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('c')
|
||||||
|
if key.code == KeyCode::Char('q')
|
||||||
|
|| key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
interrupted = true;
|
||||||
|
state.set_status(TuiStatus::Interrupted);
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let (width, height) = terminal::size()?;
|
||||||
|
force_render = state.handle_key(
|
||||||
|
key.code, Rect::new(0, 0, width, height),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = &mut retry, if retry_armed && !interrupted && !*shutdown.borrow() => {
|
||||||
|
state.process_managed_status(&status.borrow());
|
||||||
|
retry_armed = false;
|
||||||
|
update_receipt = match handle.update_targets(desired_targets.clone()) {
|
||||||
|
Ok(receipt) => Some(receipt),
|
||||||
|
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed)
|
||||||
|
if stop_requested || *shutdown.borrow() => None,
|
||||||
|
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
result = async { update_receipt.as_mut().unwrap().await },
|
||||||
|
if update_receipt.is_some() => {
|
||||||
|
update_receipt = None;
|
||||||
|
match result {
|
||||||
|
Ok(ack) => {
|
||||||
|
state.process_managed_status(&ack.status);
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
Err(ManagedCommandApplyError::Stopping
|
||||||
|
| ManagedCommandApplyError::AcknowledgementDisconnected)
|
||||||
|
if stop_requested || *shutdown.borrow() => {}
|
||||||
|
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = &mut render, if !state.paused => force_render = true,
|
||||||
}
|
}
|
||||||
if request_managed_stop_for_peer_close(
|
if interrupted && request_managed_stop_once(&mut stop_requested) {
|
||||||
continuous,
|
|
||||||
interrupted,
|
|
||||||
handle.status().peer_closed_target_outcomes,
|
|
||||||
&mut stop_requested,
|
|
||||||
) {
|
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
}
|
}
|
||||||
let previous_dropped = dropped_events;
|
if continuous
|
||||||
let drain_state = drain_tui_events(
|
&& !interrupted
|
||||||
&mut events,
|
&& !stop_requested
|
||||||
&mut state,
|
&& !retry_armed
|
||||||
&mut terminal_targets,
|
&& update_receipt.is_none()
|
||||||
&mut dropped_events,
|
&& status.borrow().targets.iter().any(|target| {
|
||||||
);
|
target.desired
|
||||||
if dropped_events != previous_dropped {
|
&& target.outcome.as_ref().is_some_and(|outcome| {
|
||||||
// Data loss changes the meaning of displayed statistics, even while
|
matches!(
|
||||||
// ordinary measurement refreshes are paused.
|
outcome.end_reason,
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
ManagedTargetEndReason::Failed(_)
|
||||||
|
| ManagedTargetEndReason::PeerClosed
|
||||||
|
| ManagedTargetEndReason::TestComplete
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
{
|
||||||
|
retry.as_mut().reset(Instant::now() + RETRY_DELAY);
|
||||||
|
retry_armed = true;
|
||||||
}
|
}
|
||||||
match drain_state {
|
if force_render {
|
||||||
ManagedDrainState::Empty => {
|
terminal.draw(&state)?;
|
||||||
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
// Schedule from the completed draw, never catch up missed frames.
|
||||||
}
|
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
|
||||||
ManagedDrainState::Closed => subscription_closed = true,
|
|
||||||
ManagedDrainState::BudgetExhausted => {}
|
|
||||||
}
|
}
|
||||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
};
|
||||||
break;
|
|
||||||
}
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
|
||||||
}
|
|
||||||
if interrupted {
|
|
||||||
state.set_status(TuiStatus::Interrupted);
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
|
||||||
}
|
|
||||||
state.set_status(TuiStatus::Closing);
|
state.set_status(TuiStatus::Closing);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
let outcome = owner.join()?;
|
let outcome = match outcome {
|
||||||
drain_final_tui_events(
|
Ok(outcome) => outcome,
|
||||||
&mut events,
|
Err(error) => {
|
||||||
&mut state,
|
state.set_run_error(error.to_string());
|
||||||
&mut terminal_targets,
|
terminal.draw(&state)?;
|
||||||
&mut dropped_events,
|
return Err(error);
|
||||||
);
|
}
|
||||||
|
};
|
||||||
|
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||||
|
process_tui_event(event, &mut state);
|
||||||
|
Ok::<(), std::convert::Infallible>(())
|
||||||
|
})?;
|
||||||
state.mark_dropped_managed_events(dropped_events);
|
state.mark_dropped_managed_events(dropped_events);
|
||||||
if outcome.discarded_target_outcomes != 0 {
|
if !continuous && outcome.discarded_target_outcomes != 0 {
|
||||||
state.set_run_error(format!(
|
state.set_run_error(format!(
|
||||||
"{} final target outcomes were discarded",
|
"{} final target outcomes were discarded",
|
||||||
outcome.discarded_target_outcomes
|
outcome.discarded_target_outcomes
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for target in outcome.recent_target_outcomes.iter() {
|
for target in outcome.recent_target_outcomes.iter() {
|
||||||
if terminal_targets.insert(target.target.clone()) {
|
state.process_target_outcome(target);
|
||||||
state.process_target_outcome(target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
interrupted |= *shutdown.borrow();
|
||||||
let error = match &outcome.end_reason {
|
let error = match &outcome.end_reason {
|
||||||
ManagedEndReason::DriverFailed(failure) => {
|
ManagedEndReason::DriverFailed(failure) => {
|
||||||
Some(format!("managed driver failed: {failure}"))
|
Some(format!("managed driver failed: {failure}"))
|
||||||
}
|
}
|
||||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
_ => (!interrupted
|
||||||
.or_else(|| {
|
&& outcome.successful_target_outcomes == 0
|
||||||
(!interrupted
|
&& outcome.failed_target_outcomes > 0)
|
||||||
&& outcome.successful_target_outcomes == 0
|
.then(|| {
|
||||||
&& outcome.failed_target_outcomes > 0)
|
format!(
|
||||||
.then(|| {
|
"no managed target completed successfully ({} failed)",
|
||||||
format!(
|
outcome.failed_target_outcomes
|
||||||
"no managed target completed successfully ({} failed)",
|
)
|
||||||
outcome.failed_target_outcomes
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
if let Some(error) = error {
|
if let Some(error) = error {
|
||||||
state.set_run_error(error.clone());
|
state.set_run_error(error.clone());
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
state.set_status(TuiStatus::Complete);
|
state.set_status(TuiStatus::Complete);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_tui_event(
|
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
|
||||||
event: ManagedEvent,
|
|
||||||
state: &mut TuiState,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
) {
|
|
||||||
match event {
|
match event {
|
||||||
ManagedEvent::Client { target, event } => state.process_target_event(&target, &event),
|
ManagedEvent::Client { target, event } => {
|
||||||
|
let force_render = matches!(
|
||||||
|
event,
|
||||||
|
ClientEvent::SessionStarted(_)
|
||||||
|
| ClientEvent::NoTestCompleted(_)
|
||||||
|
| ClientEvent::SessionClosed { .. }
|
||||||
|
| ClientEvent::Warning { .. }
|
||||||
|
);
|
||||||
|
state.process_target_event(&target, &event);
|
||||||
|
force_render
|
||||||
|
}
|
||||||
ManagedEvent::TargetFinished { outcome } => {
|
ManagedEvent::TargetFinished { outcome } => {
|
||||||
terminal_targets.insert(outcome.target.clone());
|
|
||||||
state.process_target_outcome(&outcome);
|
state.process_target_outcome(&outcome);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
_ => {}
|
ManagedEvent::TargetStateChanged {
|
||||||
}
|
target,
|
||||||
}
|
lifecycle:
|
||||||
fn drain_tui_events(
|
ManagedTargetLifecycle::Pending
|
||||||
events: &mut ManagedEventSubscription,
|
| ManagedTargetLifecycle::Connecting
|
||||||
state: &mut TuiState,
|
| ManagedTargetLifecycle::Opening,
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
} => {
|
||||||
dropped_events: &mut u64,
|
state.process_target_opening(&target);
|
||||||
) -> ManagedDrainState {
|
true
|
||||||
let previous_dropped = *dropped_events;
|
}
|
||||||
let drained = drain_managed_events(events, dropped_events, |event| {
|
_ => false,
|
||||||
process_tui_event(event, state, terminal_targets);
|
|
||||||
Ok::<(), std::convert::Infallible>(())
|
|
||||||
})
|
|
||||||
.expect("processing TUI managed events is infallible");
|
|
||||||
if *dropped_events != previous_dropped {
|
|
||||||
state.mark_dropped_managed_events(*dropped_events);
|
|
||||||
}
|
|
||||||
drained
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_final_tui_events(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
state: &mut TuiState,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) {
|
|
||||||
loop {
|
|
||||||
match events.try_recv() {
|
|
||||||
Ok(event) => process_tui_event(event, state, terminal_targets),
|
|
||||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
|
||||||
*dropped_events = dropped_events.saturating_add(count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
|
||||||
let mut force_render = false;
|
|
||||||
for _ in 0..INPUT_EVENT_WORK_BUDGET {
|
|
||||||
if !event::poll(Duration::ZERO)? {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let Event::Key(key) = event::read()? else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if key.kind == KeyEventKind::Release {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') | KeyCode::Char('c')
|
|
||||||
if key.code == KeyCode::Char('q')
|
|
||||||
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
|
|
||||||
{
|
|
||||||
state.quit_requested = true;
|
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
|
||||||
force_render = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') => {
|
|
||||||
state.clear_visible_history();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('p') => {
|
|
||||||
state.toggle_pause();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('g') => {
|
|
||||||
state.toggle_view();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('m') => {
|
|
||||||
state.cycle_graph_metric();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
state.pan_graph_left();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
state.pan_graph_right();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
state.pan_graph_page_left();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
state.pan_graph_page_right();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Home => {
|
|
||||||
state.jump_graph_oldest();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::End => {
|
|
||||||
state.jump_graph_live();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
|
||||||
state.zoom_graph_in();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('-') => {
|
|
||||||
state.zoom_graph_out();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('0') => {
|
|
||||||
state.reset_graph_window();
|
|
||||||
force_render = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(force_render)
|
|
||||||
}
|
|
||||||
fn render_if_due(
|
|
||||||
terminal: &mut TuiTerminal,
|
|
||||||
state: &TuiState,
|
|
||||||
next_render: &mut Instant,
|
|
||||||
force: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let now = Instant::now();
|
|
||||||
if should_render(now, *next_render, state.paused, force) {
|
|
||||||
terminal.draw(state)?;
|
|
||||||
*next_render = now + RENDER_INTERVAL;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
|
|
||||||
let render_wait = if paused {
|
|
||||||
TUI_WAIT_SLICE
|
|
||||||
} else {
|
|
||||||
next_render.saturating_duration_since(Instant::now())
|
|
||||||
};
|
|
||||||
render_wait.min(TUI_WAIT_SLICE)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ratatui::{backend::TestBackend, Terminal};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
|
|
||||||
for paused in [false, true] {
|
|
||||||
// The production subscription is a broadcast receiver. Overflow the
|
|
||||||
// real channel rather than synthesizing a lag result or UI state.
|
|
||||||
let (sender, mut events) = tokio::sync::broadcast::channel(1);
|
|
||||||
sender.send(ManagedEvent::Started).unwrap();
|
|
||||||
sender.send(ManagedEvent::Started).unwrap();
|
|
||||||
let mut state = TuiState::default();
|
|
||||||
if paused {
|
|
||||||
state.toggle_pause();
|
|
||||||
}
|
|
||||||
let mut dropped = 0;
|
|
||||||
let mut terminal_targets = HashSet::new();
|
|
||||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
|
||||||
assert_eq!(dropped, 1);
|
|
||||||
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
|
|
||||||
for _ in 0..2 {
|
|
||||||
// Empty drains must not clear the warning on later redraws.
|
|
||||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
|
||||||
terminal
|
|
||||||
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
|
|
||||||
.unwrap();
|
|
||||||
let text = terminal
|
|
||||||
.backend()
|
|
||||||
.buffer()
|
|
||||||
.content()
|
|
||||||
.iter()
|
|
||||||
.map(|cell| cell.symbol())
|
|
||||||
.collect::<String>();
|
|
||||||
assert!(text.contains("incomplete:dropped=1"));
|
|
||||||
assert_eq!(text.contains("display paused"), paused);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1197
-994
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
|||||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
#[cfg(feature = "server")]
|
||||||
use std::sync::{atomic::AtomicBool, Arc};
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
use std::{env, ffi::OsString, process::ExitCode};
|
use std::{env, ffi::OsString, process::ExitCode};
|
||||||
|
|
||||||
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
||||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
#[cfg(feature = "server")]
|
||||||
use crate::signal::install_signal_handler;
|
use crate::signal::install_signal_handler;
|
||||||
|
|
||||||
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
||||||
@@ -75,9 +75,9 @@ fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs the shutdown signal handler shared by every applet and hands the
|
/// Installs the shutdown signal handler for synchronous applets and hands the
|
||||||
/// resulting flag to `f`.
|
/// resulting flag to `f`.
|
||||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
#[cfg(feature = "server")]
|
||||||
fn with_shutdown_flag(
|
fn with_shutdown_flag(
|
||||||
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -89,12 +89,14 @@ fn with_shutdown_flag(
|
|||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use clap::Parser;
|
let shutdown = crate::signal::install_async_signal_handler()
|
||||||
|
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||||
with_shutdown_flag(|shutdown_requested| {
|
let args =
|
||||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
crate::version::parse::<crate::cmd::client::ClientArgs>(argv, crate::version::client);
|
||||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
tokio::runtime::Builder::new_current_thread()
|
||||||
})
|
.enable_all()
|
||||||
|
.build()?
|
||||||
|
.block_on(crate::cmd::client::run_stream(args, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "client"))]
|
#[cfg(not(feature = "client"))]
|
||||||
@@ -104,10 +106,9 @@ fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
with_shutdown_flag(|shutdown_requested| {
|
with_shutdown_flag(|shutdown_requested| {
|
||||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
let args =
|
||||||
|
crate::version::parse::<crate::cmd::server::ServerArgs>(argv, crate::version::server);
|
||||||
crate::cmd::server::run_server(args, shutdown_requested)
|
crate::cmd::server::run_server(args, shutdown_requested)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -119,12 +120,13 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
|
|||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use clap::Parser;
|
let shutdown = crate::signal::install_async_signal_handler()
|
||||||
|
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||||
with_shutdown_flag(|shutdown_requested| {
|
let args = crate::version::parse::<crate::cmd::tui::TuiArgs>(argv, crate::version::client);
|
||||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
tokio::runtime::Builder::new_current_thread()
|
||||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
.enable_all()
|
||||||
})
|
.build()?
|
||||||
|
.block_on(crate::cmd::tui::run_tui(args, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "tui"))]
|
#[cfg(not(feature = "tui"))]
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ pub mod cmd;
|
|||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
|
|
||||||
|
mod version;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{fmt, time::Duration};
|
use std::{fmt, net::IpAddr, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
|
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
|
||||||
@@ -6,16 +6,33 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|||||||
|
|
||||||
#[derive(Clone, clap::Args)]
|
#[derive(Clone, clap::Args)]
|
||||||
pub struct CommonClientArgs {
|
pub struct CommonClientArgs {
|
||||||
/// Probe interval.
|
/// Use IPv4 only.
|
||||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
#[arg(short = '4', long, conflicts_with_all = ["ipv6", "dual_stack"])]
|
||||||
|
pub ipv4: bool,
|
||||||
|
|
||||||
|
/// Use IPv6 only.
|
||||||
|
#[arg(short = '6', long, conflicts_with_all = ["ipv4", "dual_stack"])]
|
||||||
|
pub ipv6: bool,
|
||||||
|
|
||||||
|
/// Probe available IPv4 and IPv6 paths separately for each hostname.
|
||||||
|
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
|
||||||
|
pub dual_stack: bool,
|
||||||
|
|
||||||
|
/// Positive probe interval (e.g. 2.5s, 1m30s, 500us, 100ns, 1h).
|
||||||
|
#[arg(short = 'i', long, default_value = "1s", value_parser = parse_duration)]
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
|
|
||||||
|
/// Comma-separated session-opening timeouts, one per attempt (each >= 200ms).
|
||||||
|
#[arg(long, value_name = "DURATIONS", value_delimiter = ',', value_parser = parse_duration, long_help = "Comma-separated session-opening receive timeouts, one per attempt (each at least 200ms), e.g. 1s,2s,4s,8s. Omit to use the library default retry schedule.")]
|
||||||
|
pub timeouts: Vec<Duration>,
|
||||||
|
|
||||||
/// UDP payload length.
|
/// UDP payload length.
|
||||||
#[arg(long, default_value_t = 0, value_parser = parse_length)]
|
#[arg(short = 'l', long, default_value_t = 0, value_parser = parse_length)]
|
||||||
pub length: u32,
|
pub length: u32,
|
||||||
|
|
||||||
/// HMAC key.
|
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||||
#[arg(long, value_parser = parse_hmac_key)]
|
/// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
|
||||||
|
#[arg(long)]
|
||||||
pub hmac: Option<String>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
/// Clock mode to request.
|
/// Clock mode to request.
|
||||||
@@ -58,6 +75,38 @@ pub struct CommonClientArgs {
|
|||||||
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
|
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
|
||||||
pub ttl: Option<u32>,
|
pub ttl: Option<u32>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long = "bind-addr",
|
||||||
|
value_name = "IP[:PORT]",
|
||||||
|
value_parser = parse_bind_addr,
|
||||||
|
long_help = "Local IP address and optional UDP port to bind. \
|
||||||
|
If no port is specified, the operating system selects \
|
||||||
|
an ephemeral port. IPv6 addresses with an explicit \
|
||||||
|
port must use [ADDR]:PORT syntax."
|
||||||
|
)]
|
||||||
|
pub bind_addr: Option<SocketAddr>,
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
#[arg(
|
||||||
|
long = "bind-device",
|
||||||
|
value_name = "DEVICE",
|
||||||
|
help = "Bind the socket to a network device"
|
||||||
|
)]
|
||||||
|
pub bind_to_device: Option<String>,
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "MARK",
|
||||||
|
value_parser = clap_num::maybe_hex::<u32>,
|
||||||
|
help = "Set the outgoing socket firewall mark"
|
||||||
|
)]
|
||||||
|
pub mark: Option<u32>,
|
||||||
|
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
|
#[arg(long, value_name = "FIB", help = "Set the socket routing table (FIB)")]
|
||||||
|
pub fib: Option<u32>,
|
||||||
|
|
||||||
/// Accept safe server restrictions during negotiation.
|
/// Accept safe server restrictions during negotiation.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub loose: bool,
|
pub loose: bool,
|
||||||
@@ -65,8 +114,13 @@ pub struct CommonClientArgs {
|
|||||||
|
|
||||||
impl fmt::Debug for CommonClientArgs {
|
impl fmt::Debug for CommonClientArgs {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("CommonClientArgs")
|
let mut debug = f.debug_struct("CommonClientArgs");
|
||||||
|
debug
|
||||||
|
.field("ipv4", &self.ipv4)
|
||||||
|
.field("ipv6", &self.ipv6)
|
||||||
|
.field("dual_stack", &self.dual_stack)
|
||||||
.field("interval", &self.interval)
|
.field("interval", &self.interval)
|
||||||
|
.field("timeouts", &self.timeouts)
|
||||||
.field("length", &self.length)
|
.field("length", &self.length)
|
||||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
.field("clock", &self.clock)
|
.field("clock", &self.clock)
|
||||||
@@ -75,8 +129,14 @@ impl fmt::Debug for CommonClientArgs {
|
|||||||
.field("server_fill", &self.server_fill)
|
.field("server_fill", &self.server_fill)
|
||||||
.field("dscp", &self.dscp)
|
.field("dscp", &self.dscp)
|
||||||
.field("ttl", &self.ttl)
|
.field("ttl", &self.ttl)
|
||||||
.field("loose", &self.loose)
|
.field("bind_addr", &self.bind_addr);
|
||||||
.finish()
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
debug
|
||||||
|
.field("bind_to_device", &self.bind_to_device)
|
||||||
|
.field("mark", &self.mark);
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
|
debug.field("fib", &self.fib);
|
||||||
|
debug.field("loose", &self.loose).finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,63 +197,26 @@ impl From<ReceivedStatsArg> for ReceivedStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_bind_addr(input: &str) -> Result<SocketAddr, String> {
|
||||||
|
input
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.or_else(|_| input.parse::<IpAddr>().map(|ip| SocketAddr::new(ip, 0)))
|
||||||
|
.map_err(|_| "expected an IP address, optionally with a port".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_duration(input: &str) -> Result<Duration, String> {
|
pub fn parse_duration(input: &str) -> Result<Duration, String> {
|
||||||
let (number, unit) = split_duration(input)?;
|
let duration = parse_test_duration(input)?;
|
||||||
let value: u64 = number
|
if duration.is_zero() {
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
|
||||||
if value == 0 {
|
|
||||||
return Err("duration must be greater than zero".to_owned());
|
return Err("duration must be greater than zero".to_owned());
|
||||||
}
|
}
|
||||||
match unit {
|
Ok(duration)
|
||||||
"ms" => Ok(Duration::from_millis(value)),
|
|
||||||
"s" => Ok(Duration::from_secs(value)),
|
|
||||||
"m" => value
|
|
||||||
.checked_mul(60)
|
|
||||||
.map(Duration::from_secs)
|
|
||||||
.ok_or_else(|| "duration is too large".to_owned()),
|
|
||||||
_ => Err(format!(
|
|
||||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
|
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
|
||||||
if input == "0" {
|
if input == "0" {
|
||||||
return Ok(Duration::ZERO);
|
return Ok(Duration::ZERO);
|
||||||
}
|
}
|
||||||
let (number, unit) = split_duration(input)?;
|
humantime::parse_duration(input).map_err(|error| error.to_string())
|
||||||
let value: u64 = number
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
|
||||||
if value == 0 {
|
|
||||||
return Ok(Duration::ZERO);
|
|
||||||
}
|
|
||||||
match unit {
|
|
||||||
"ms" => Ok(Duration::from_millis(value)),
|
|
||||||
"s" => Ok(Duration::from_secs(value)),
|
|
||||||
"m" => value
|
|
||||||
.checked_mul(60)
|
|
||||||
.map(Duration::from_secs)
|
|
||||||
.ok_or_else(|| "duration is too large".to_owned()),
|
|
||||||
_ => Err(format!(
|
|
||||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_duration(input: &str) -> Result<(&str, &str), String> {
|
|
||||||
let split = input
|
|
||||||
.find(|ch: char| !ch.is_ascii_digit())
|
|
||||||
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
|
|
||||||
let (number, unit) = input.split_at(split);
|
|
||||||
if number.is_empty() || unit.is_empty() {
|
|
||||||
return Err("duration must include a positive value and unit".to_owned());
|
|
||||||
}
|
|
||||||
if number.starts_with('-') {
|
|
||||||
return Err("duration must be greater than zero".to_owned());
|
|
||||||
}
|
|
||||||
Ok((number, unit))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_length(input: &str) -> Result<u32, String> {
|
pub fn parse_length(input: &str) -> Result<u32, String> {
|
||||||
@@ -206,13 +229,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
|
|||||||
Ok(length)
|
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> {
|
pub fn parse_server_fill(input: &str) -> Result<String, String> {
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Err("server fill must not be empty".to_owned());
|
return Err("server fill must not be empty".to_owned());
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
|
||||||
SocketConfig,
|
SessionRequest, SocketConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::args::CommonClientArgs;
|
use super::args::CommonClientArgs;
|
||||||
|
|
||||||
impl CommonClientArgs {
|
impl CommonClientArgs {
|
||||||
|
pub fn address_family(&self) -> AddressFamily {
|
||||||
|
if self.ipv4 {
|
||||||
|
AddressFamily::Ipv4
|
||||||
|
} else if self.ipv6 {
|
||||||
|
AddressFamily::Ipv6
|
||||||
|
} else {
|
||||||
|
AddressFamily::Any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the shared client configuration template for a managed run.
|
/// Build the shared client configuration template for a managed run.
|
||||||
///
|
///
|
||||||
/// The config is endpoint-independent; managed targets own their addresses.
|
/// The config is endpoint-independent; managed targets own their addresses.
|
||||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
pub fn to_client_config(&self, duration: Duration) -> Result<ClientConfig, String> {
|
||||||
ClientConfig {
|
let mut open = OpenPolicy {
|
||||||
open: OpenPolicy {
|
negotiation: if self.loose {
|
||||||
negotiation: if self.loose {
|
NegotiationPolicy::Loose
|
||||||
NegotiationPolicy::Loose
|
} else {
|
||||||
} else {
|
NegotiationPolicy::Strict
|
||||||
NegotiationPolicy::Strict
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if !self.timeouts.is_empty() {
|
||||||
|
open.timeouts.clone_from(&self.timeouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ClientConfig {
|
||||||
|
address_family: self.address_family(),
|
||||||
|
open,
|
||||||
request: SessionRequest {
|
request: SessionRequest {
|
||||||
duration: (!duration.is_zero()).then_some(duration),
|
duration: (!duration.is_zero()).then_some(duration),
|
||||||
interval: self.interval,
|
interval: self.interval,
|
||||||
@@ -34,16 +50,24 @@ impl CommonClientArgs {
|
|||||||
},
|
},
|
||||||
auth: self
|
auth: self
|
||||||
.hmac
|
.hmac
|
||||||
.as_ref()
|
.as_deref()
|
||||||
|
.map(crate::shared::decode_hmac_key)
|
||||||
|
.transpose()?
|
||||||
.map_or(Authentication::Unauthenticated, |key| {
|
.map_or(Authentication::Unauthenticated, |key| {
|
||||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
Authentication::Hmac(HmacKey::new(key))
|
||||||
}),
|
}),
|
||||||
socket: SocketConfig {
|
socket: SocketConfig {
|
||||||
|
bind_addr: self.bind_addr,
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
bind_to_device: self.bind_to_device.clone(),
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
mark: self.mark,
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
|
fib: self.fib,
|
||||||
ttl: self.ttl,
|
ttl: self.ttl,
|
||||||
..SocketConfig::default()
|
|
||||||
},
|
},
|
||||||
..ClientConfig::default()
|
..ClientConfig::default()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod config;
|
|||||||
pub mod prepare;
|
pub mod prepare;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod targets;
|
pub mod targets;
|
||||||
|
pub(crate) mod worker;
|
||||||
|
|
||||||
pub use args::{
|
pub use args::{
|
||||||
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
||||||
@@ -13,8 +14,7 @@ pub use prepare::{
|
|||||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
pub use session::is_shutdown_requested;
|
|
||||||
pub use targets::{
|
pub use targets::{
|
||||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||||
PreparedTarget, TargetArg, TargetSpec,
|
TargetPreparation, TargetSpec,
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,7 @@ use irtt_client::{
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
args::CommonClientArgs,
|
args::CommonClientArgs,
|
||||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
|
||||||
PreparedTarget,
|
PreparedTarget,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
|
|||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
/// Whether this stream may replace its desired set from stdin.
|
/// Whether this stream may replace its desired set from stdin.
|
||||||
pub stdin_controlled: bool,
|
pub stdin_controlled: bool,
|
||||||
|
/// Preparation policy and family selections for later stdin declarations.
|
||||||
|
pub preparation: TargetPreparation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedRunSetup {
|
impl ManagedRunSetup {
|
||||||
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
|
|||||||
///
|
///
|
||||||
/// Targets are validated before any configuration is built, so a bad target set
|
/// Targets are validated before any configuration is built, so a bad target set
|
||||||
/// fails before the caller can act on a half-prepared run.
|
/// fails before the caller can act on a half-prepared run.
|
||||||
pub fn prepare_managed_run(
|
pub async fn prepare_managed_run(
|
||||||
common: &CommonClientArgs,
|
common: &CommonClientArgs,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
selection: TargetSelection<'_>,
|
selection: TargetSelection<'_>,
|
||||||
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
|
|||||||
} else {
|
} else {
|
||||||
target_specs(selection.targets)?
|
target_specs(selection.targets)?
|
||||||
};
|
};
|
||||||
let targets = prepare_managed_targets(specs)?;
|
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
|
||||||
|
.prepare(
|
||||||
|
specs,
|
||||||
|
selection
|
||||||
|
.stdin_controlled
|
||||||
|
.then_some(STDIN_MAX_DESIRED_TARGETS),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(ManagedRunSetup {
|
Ok(ManagedRunSetup {
|
||||||
targets,
|
targets,
|
||||||
client: common.to_client_config(duration),
|
client: common.to_client_config(duration)?,
|
||||||
pacing: selection.pacing.into(),
|
pacing: selection.pacing.into(),
|
||||||
stdin_controlled: selection.stdin_controlled,
|
stdin_controlled: selection.stdin_controlled,
|
||||||
|
preparation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,22 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
#[cfg(feature = "tui")]
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
#[cfg(feature = "tui")]
|
||||||
pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128;
|
pub(crate) fn drain_final_events<E>(
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub(crate) enum ManagedDrainState {
|
|
||||||
Empty,
|
|
||||||
Closed,
|
|
||||||
BudgetExhausted,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain a bounded amount of lossy presentation work before returning to the
|
|
||||||
/// frontend control loop.
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
pub(crate) fn drain_managed_events<E>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
events: &mut ManagedEventSubscription,
|
||||||
dropped_events: &mut u64,
|
dropped_events: &mut u64,
|
||||||
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
||||||
) -> Result<ManagedDrainState, E> {
|
) -> Result<(), E> {
|
||||||
for _ in 0..MANAGED_EVENT_WORK_BUDGET {
|
loop {
|
||||||
match events.try_recv() {
|
match events.try_recv() {
|
||||||
Ok(event) => process(event)?,
|
Ok(event) => process(event)?,
|
||||||
|
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||||
*dropped_events = dropped_events.saturating_add(count);
|
*dropped_events = dropped_events.saturating_add(count);
|
||||||
}
|
}
|
||||||
Err(ManagedEventTryRecvError::Empty) => return Ok(ManagedDrainState::Empty),
|
|
||||||
Err(ManagedEventTryRecvError::Closed) => return Ok(ManagedDrainState::Closed),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ManagedDrainState::BudgetExhausted)
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
shutdown_requested.load(Ordering::Relaxed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
use std::{collections::HashSet, fmt};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt, io,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||||
Authentication, HmacKey,
|
AddressFamily, Authentication, HmacKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One raw positional target captured by Clap.
|
/// One raw positional target captured by Clap.
|
||||||
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
|
|||||||
f.debug_struct("PreparedTarget")
|
f.debug_struct("PreparedTarget")
|
||||||
.field("label", &self.label)
|
.field("label", &self.label)
|
||||||
.field("server_addr", &self.managed.server_addr)
|
.field("server_addr", &self.managed.server_addr)
|
||||||
|
.field("address_family", &self.managed.address_family)
|
||||||
.field("auth", &self.managed.auth)
|
.field("auth", &self.managed.auth)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -83,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum TargetParseError {
|
enum TargetParseError {
|
||||||
EmptyLabel,
|
EmptyLabel,
|
||||||
EmptyAddress,
|
EmptyAddress,
|
||||||
InvalidEscape,
|
InvalidEscape,
|
||||||
EmptyTarget,
|
EmptyTarget,
|
||||||
|
InvalidHmac(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for TargetParseError {
|
impl fmt::Display for TargetParseError {
|
||||||
@@ -98,6 +104,7 @@ impl fmt::Display for TargetParseError {
|
|||||||
Self::EmptyAddress => "target address must not be empty",
|
Self::EmptyAddress => "target address must not be empty",
|
||||||
Self::InvalidEscape => "invalid target escape sequence",
|
Self::InvalidEscape => "invalid target escape sequence",
|
||||||
Self::EmptyTarget => "target must not be empty",
|
Self::EmptyTarget => "target must not be empty",
|
||||||
|
Self::InvalidHmac(message) => message,
|
||||||
};
|
};
|
||||||
f.write_str(message)
|
f.write_str(message)
|
||||||
}
|
}
|
||||||
@@ -192,7 +199,10 @@ fn parse_target_syntax(
|
|||||||
let auth = if value.is_empty() {
|
let auth = if value.is_empty() {
|
||||||
TargetAuth::Override(Authentication::Unauthenticated)
|
TargetAuth::Override(Authentication::Unauthenticated)
|
||||||
} else {
|
} else {
|
||||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
||||||
|
crate::shared::decode_hmac_key(&value)
|
||||||
|
.map_err(TargetParseError::InvalidHmac)?,
|
||||||
|
)))
|
||||||
};
|
};
|
||||||
(&input[..index], auth)
|
(&input[..index], auth)
|
||||||
}
|
}
|
||||||
@@ -257,17 +267,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
|||||||
target_specs_with_empty(targets, false)
|
target_specs_with_empty(targets, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
/// Family selections for the current desired declarations, never resolved IPs.
|
||||||
let mut targets = Vec::with_capacity(specs.len());
|
/// Unchanged stdin declarations retain their selections; removed declarations
|
||||||
for spec in specs {
|
/// are forgotten, so adding them again performs fresh discovery.
|
||||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
#[derive(Debug, Clone)]
|
||||||
managed.auth = spec.auth;
|
pub struct TargetPreparation {
|
||||||
targets.push(PreparedTarget {
|
family: AddressFamily,
|
||||||
label: spec.label,
|
dual_stack: bool,
|
||||||
managed,
|
discovered: HashMap<String, (String, [bool; 2])>,
|
||||||
});
|
}
|
||||||
|
|
||||||
|
impl TargetPreparation {
|
||||||
|
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
family,
|
||||||
|
dual_stack,
|
||||||
|
discovered: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(targets)
|
|
||||||
|
pub async fn prepare(
|
||||||
|
mut self,
|
||||||
|
specs: Vec<TargetSpec>,
|
||||||
|
maximum_targets: Option<usize>,
|
||||||
|
) -> Result<(Vec<PreparedTarget>, Self), String> {
|
||||||
|
let mut targets = Vec::with_capacity(specs.len());
|
||||||
|
let mut discovered = HashMap::new();
|
||||||
|
let mut labels = HashSet::new();
|
||||||
|
for (index, spec) in specs.into_iter().enumerate() {
|
||||||
|
let families = if let Some(ip) = literal_ip(&spec.addr) {
|
||||||
|
match (self.family, ip) {
|
||||||
|
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"target {}: IPv6 literal conflicts with --ipv4",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"target {}: IPv4 literal conflicts with --ipv6",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else if self.dual_stack {
|
||||||
|
let families = match self.discovered.get(&spec.label) {
|
||||||
|
Some((addr, families)) if *addr == spec.addr => *families,
|
||||||
|
_ => {
|
||||||
|
let endpoint = discovery_endpoint(&spec.addr)
|
||||||
|
.map_err(|error| format!("target {}: {error}", index + 1))?;
|
||||||
|
discovery_families(tokio::net::lookup_host(endpoint).await)
|
||||||
|
.map_err(|error| format!("target {}: {error}", index + 1))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
|
||||||
|
Some(families)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
for target in expand_target(spec, families) {
|
||||||
|
if !labels.insert(target.label.clone()) {
|
||||||
|
return Err("duplicate target label after address-family expansion".to_owned());
|
||||||
|
}
|
||||||
|
targets.push(target);
|
||||||
|
}
|
||||||
|
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
|
||||||
|
return Err(format!(
|
||||||
|
"target set exceeds the {maximum}-target limit after address-family expansion"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.discovered = discovered;
|
||||||
|
Ok((targets, self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
|
||||||
|
endpoint
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.map(|addr| addr.ip())
|
||||||
|
.ok()
|
||||||
|
.or_else(|| endpoint.parse().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
if endpoint.starts_with('[') && endpoint.ends_with(']') {
|
||||||
|
format!("{endpoint}:2112")
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.ok()
|
||||||
|
.map(|addr| addr.ip())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
|
||||||
|
match endpoint.rsplit_once(':') {
|
||||||
|
Some((host, port)) => {
|
||||||
|
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
|
||||||
|
if host.is_empty() || host.contains(':') || host.starts_with('[') {
|
||||||
|
return Err("invalid server endpoint".to_owned());
|
||||||
|
}
|
||||||
|
Ok((host, port))
|
||||||
|
}
|
||||||
|
None => Ok((endpoint, 2112)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discovery_families(
|
||||||
|
result: io::Result<impl Iterator<Item = SocketAddr>>,
|
||||||
|
) -> Result<[bool; 2], String> {
|
||||||
|
let mut families = [false; 2];
|
||||||
|
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
|
||||||
|
families[usize::from(addr.is_ipv6())] = true;
|
||||||
|
}
|
||||||
|
if families == [false; 2] {
|
||||||
|
return Err("DNS lookup returned no usable addresses".to_owned());
|
||||||
|
}
|
||||||
|
Ok(families)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
|
||||||
|
let selections = match families {
|
||||||
|
None => vec![(spec.label, None)],
|
||||||
|
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
|
||||||
|
.into_iter()
|
||||||
|
.zip(families)
|
||||||
|
.filter(|(_, exists)| *exists)
|
||||||
|
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
selections
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, family)| {
|
||||||
|
let mut managed =
|
||||||
|
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
|
||||||
|
managed.address_family = family;
|
||||||
|
managed.auth = spec.auth.clone();
|
||||||
|
PreparedTarget { label, managed }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one complete stdin target set after its line terminator was removed.
|
/// Parse one complete stdin target set after its line terminator was removed.
|
||||||
@@ -277,7 +416,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
|||||||
pub fn parse_stdin_target_set(
|
pub fn parse_stdin_target_set(
|
||||||
record: &str,
|
record: &str,
|
||||||
maximum_targets: usize,
|
maximum_targets: usize,
|
||||||
) -> Result<Vec<PreparedTarget>, String> {
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
if record == "[]" {
|
if record == "[]" {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -288,5 +427,5 @@ pub fn parse_stdin_target_set(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
target_specs_with_empty(&args, true)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use std::{io, thread, time::Duration};
|
||||||
|
|
||||||
|
use irtt_client::managed::{ManagedClientHandle, ManagedClientTask, ManagedOutcome};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
/// Keeps measurement scheduling separate from frontend output and statistics.
|
||||||
|
/// Drop also stops and joins on frontend errors or cancellation.
|
||||||
|
pub(crate) struct ManagedWorker {
|
||||||
|
handle: ManagedClientHandle,
|
||||||
|
thread: Option<thread::JoinHandle<()>>,
|
||||||
|
completion: oneshot::Receiver<io::Result<ManagedOutcome>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedWorker {
|
||||||
|
pub(crate) fn start(task: ManagedClientTask, handle: ManagedClientHandle) -> io::Result<Self> {
|
||||||
|
let (completed, completion) = oneshot::channel();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("irtt-managed".to_owned())
|
||||||
|
.spawn(move || {
|
||||||
|
// Construct and destroy the runtime on its owning thread, including
|
||||||
|
// startup failures; never drop a runtime inside the frontend executor.
|
||||||
|
let outcome = (|| {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
runtime.block_on(task)
|
||||||
|
}));
|
||||||
|
runtime.shutdown_timeout(Duration::from_millis(100));
|
||||||
|
match outcome {
|
||||||
|
Ok(outcome) => Ok(outcome),
|
||||||
|
Err(panic) => std::panic::resume_unwind(panic),
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
let _ = completed.send(outcome);
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
handle,
|
||||||
|
thread: Some(thread),
|
||||||
|
completion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn join(mut self) -> Result<ManagedOutcome, Box<dyn std::error::Error>> {
|
||||||
|
let outcome = (&mut self.completion).await;
|
||||||
|
self.join_thread()?;
|
||||||
|
Ok(outcome
|
||||||
|
.map_err(|_| io::Error::other("managed worker completed without an outcome"))??)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_thread(&mut self) -> io::Result<()> {
|
||||||
|
self.thread
|
||||||
|
.take()
|
||||||
|
.expect("managed worker is joined once")
|
||||||
|
.join()
|
||||||
|
.map_err(|_| io::Error::other("managed worker thread panicked"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ManagedWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.thread.is_some() {
|
||||||
|
drop(self.handle.stop());
|
||||||
|
let _ = self.join_thread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/// Decode CLI key syntax without ever including key material in diagnostics.
|
||||||
|
pub(crate) fn decode_hmac_key(input: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let key = match input.strip_prefix("0x") {
|
||||||
|
Some(hex) => hex::decode(hex).map_err(|_| {
|
||||||
|
"HMAC hexadecimal key must contain an even number of valid hexadecimal digits"
|
||||||
|
.to_owned()
|
||||||
|
})?,
|
||||||
|
None => input.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("HMAC key must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
#[cfg(any(feature = "client", feature = "tui"))]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
mod hmac;
|
||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
pub(crate) use hmac::decode_hmac_key;
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
#[cfg(feature = "server")]
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||||
ctrlc::set_handler(move || {
|
ctrlc::set_handler(move || {
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
shutdown_requested.store(true, Ordering::Relaxed);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Preserve ctrlc's SIGINT/SIGTERM policy while waking the async frontends.
|
||||||
|
#[cfg(any(feature = "client", feature = "tui"))]
|
||||||
|
pub fn install_async_signal_handler() -> Result<tokio::sync::watch::Receiver<bool>, ctrlc::Error> {
|
||||||
|
let (shutdown, receiver) = tokio::sync::watch::channel(false);
|
||||||
|
ctrlc::set_handler(move || {
|
||||||
|
shutdown.send_replace(true);
|
||||||
|
})?;
|
||||||
|
Ok(receiver)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
pub(crate) fn app() -> String {
|
||||||
|
clap::Command::new("irtt-rs")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.render_version()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve Clap's version banner and append the compiled component versions.
|
||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
pub(crate) fn parse<T: clap::Parser>(argv: Vec<OsString>, report: fn() -> String) -> T {
|
||||||
|
match T::try_parse_from(argv) {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(err) if err.kind() == clap::error::ErrorKind::DisplayVersion => {
|
||||||
|
print!("{}{}", err.render().ansi(), report());
|
||||||
|
std::process::exit(0)
|
||||||
|
}
|
||||||
|
Err(err) => err.exit(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "client", feature = "tui"))]
|
||||||
|
pub(crate) fn client() -> String {
|
||||||
|
#[cfg(feature = "stats")]
|
||||||
|
let stats = format!(" stats: {}\n", irtt_stats::VERSION);
|
||||||
|
#[cfg(not(feature = "stats"))]
|
||||||
|
let stats = " stats: disabled\n";
|
||||||
|
format!(
|
||||||
|
" client: {}\n{} proto: {}\n",
|
||||||
|
irtt_client::VERSION,
|
||||||
|
stats,
|
||||||
|
irtt_proto::VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub(crate) fn server() -> String {
|
||||||
|
format!(
|
||||||
|
" server: {}\n proto: {}\n",
|
||||||
|
irtt_server::VERSION,
|
||||||
|
irtt_proto::VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
#[cfg(feature = "client")]
|
|
||||||
#[path = "support/in_tree_server.rs"]
|
|
||||||
mod in_tree_server;
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
use irtt_server::ServerConfig;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
struct ClientProcess(std::process::Child);
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
impl Drop for ClientProcess {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.0.kill();
|
|
||||||
let _ = self.0.wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
impl ClientProcess {
|
|
||||||
fn wait(&mut self) -> std::process::ExitStatus {
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
if let Some(status) = self.0.try_wait().unwrap() {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
std::time::Instant::now() < deadline,
|
|
||||||
"client did not terminate within five seconds"
|
|
||||||
);
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch/help/availability belong to the application and run in every build.
|
|
||||||
#[test]
|
|
||||||
fn dispatcher_reports_and_runs_the_enabled_applets() {
|
|
||||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
|
||||||
let help = Command::new(dispatcher).arg("--help").output().unwrap();
|
|
||||||
assert!(help.status.success());
|
|
||||||
let help = String::from_utf8(help.stdout).unwrap();
|
|
||||||
for (applet, enabled) in [
|
|
||||||
("client", cfg!(feature = "client")),
|
|
||||||
("server", cfg!(feature = "server")),
|
|
||||||
("tui", cfg!(feature = "tui")),
|
|
||||||
] {
|
|
||||||
let row = help
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.trim_start().starts_with(applet))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(row.contains("not available"), !enabled);
|
|
||||||
let output = Command::new(dispatcher)
|
|
||||||
.args([applet, "--help"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(output.status.success(), enabled, "{applet}");
|
|
||||||
if !enabled {
|
|
||||||
assert!(String::from_utf8(output.stderr)
|
|
||||||
.unwrap()
|
|
||||||
.contains("not available"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let binaries = [
|
|
||||||
dispatcher,
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-client"),
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-server"),
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
|
||||||
];
|
|
||||||
for binary in binaries {
|
|
||||||
assert!(Command::new(binary)
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
assert!(!Command::new(binary)
|
|
||||||
.arg("--invalid-option")
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The default presentation must reach a meaningful packet summary for a finite run.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn default_client_output_emits_a_packet_summary() {
|
|
||||||
use std::{io::Read, process::Stdio, thread};
|
|
||||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
|
||||||
.env_clear()
|
|
||||||
.args([
|
|
||||||
"--duration",
|
|
||||||
"100ms",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
&server.addr.to_string(),
|
|
||||||
])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let mut stdout = client.0.stdout.take().unwrap();
|
|
||||||
let stdout = thread::spawn(move || {
|
|
||||||
let mut text = String::new();
|
|
||||||
stdout.read_to_string(&mut text).unwrap();
|
|
||||||
text
|
|
||||||
});
|
|
||||||
let mut stderr = client.0.stderr.take().unwrap();
|
|
||||||
let stderr = thread::spawn(move || {
|
|
||||||
let mut text = String::new();
|
|
||||||
stderr.read_to_string(&mut text).unwrap();
|
|
||||||
text
|
|
||||||
});
|
|
||||||
let status = client.wait();
|
|
||||||
let stdout = stdout.join().unwrap();
|
|
||||||
let stderr = stderr.join().unwrap();
|
|
||||||
assert!(status.success(), "{stderr}");
|
|
||||||
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
|
|
||||||
let packets = stdout
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.starts_with("packets:"))
|
|
||||||
.expect("default output must include the packet summary");
|
|
||||||
for field in ["sent=", "received="] {
|
|
||||||
let count = packets
|
|
||||||
.split_whitespace()
|
|
||||||
.find_map(|word| word.strip_prefix(field))
|
|
||||||
.unwrap()
|
|
||||||
.parse::<u64>()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
count > 0,
|
|
||||||
"summary must report {field} a positive count: {packets}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
|
|
||||||
use clap::Parser;
|
|
||||||
use irtt_app::cmd::client::args::ClientArgs;
|
|
||||||
use irtt_client::{managed::TargetAuth, Authentication, HmacKey};
|
|
||||||
let args = ClientArgs::try_parse_from([
|
|
||||||
"irtt-client",
|
|
||||||
"--hmac",
|
|
||||||
"CONSPICUOUS_GLOBAL_HMAC_KEY",
|
|
||||||
"inherit=127.0.0.1:2112",
|
|
||||||
"override=127.0.0.1:2113@hmac=CONSPICUOUS_TARGET_HMAC_KEY",
|
|
||||||
"disable=127.0.0.1:2114@hmac=",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
let setup = args.prepare().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
setup.managed_config().client.auth,
|
|
||||||
Authentication::Hmac(HmacKey::new(b"CONSPICUOUS_GLOBAL_HMAC_KEY".as_slice()))
|
|
||||||
);
|
|
||||||
let targets = setup.managed_targets();
|
|
||||||
let authentication: Vec<_> = targets.iter().map(|target| target.auth.clone()).collect();
|
|
||||||
assert_eq!(
|
|
||||||
authentication,
|
|
||||||
[
|
|
||||||
TargetAuth::Inherit,
|
|
||||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
|
||||||
b"CONSPICUOUS_TARGET_HMAC_KEY".as_slice()
|
|
||||||
))),
|
|
||||||
TargetAuth::Override(Authentication::Unauthenticated)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
for output in [format!("{args:?}"), format!("{setup:?}")] {
|
|
||||||
assert!(!output.contains("CONSPICUOUS_GLOBAL_HMAC_KEY"));
|
|
||||||
assert!(!output.contains("CONSPICUOUS_TARGET_HMAC_KEY"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real authenticated server detects broken client argument-to-config mapping.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn client_arguments_export_a_real_client_run() {
|
|
||||||
use std::{io::Read, process::Stdio, thread};
|
|
||||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
|
||||||
for option in ["--dscp", "--ttl"] {
|
|
||||||
assert!(!Command::new(dispatcher)
|
|
||||||
.args(["client", option, "256", "127.0.0.1:9"])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
}
|
|
||||||
let server =
|
|
||||||
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(dispatcher)
|
|
||||||
.args([
|
|
||||||
"client",
|
|
||||||
"--duration",
|
|
||||||
"100ms",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
"--hmac",
|
|
||||||
"cli-test",
|
|
||||||
"--length",
|
|
||||||
"128",
|
|
||||||
"--sfill",
|
|
||||||
"pattern:ab",
|
|
||||||
"--clock",
|
|
||||||
"wall",
|
|
||||||
"--tstamp",
|
|
||||||
"both",
|
|
||||||
"--stats",
|
|
||||||
"both",
|
|
||||||
"--ttl",
|
|
||||||
"64",
|
|
||||||
"--format",
|
|
||||||
"jsonl",
|
|
||||||
"--columns",
|
|
||||||
"target,event,bytes",
|
|
||||||
&format!("edge={}", server.addr),
|
|
||||||
])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
// Drain both pipes while waiting so output cannot block client termination.
|
|
||||||
let mut stdout = client.0.stdout.take().unwrap();
|
|
||||||
let stdout = thread::spawn(move || {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
stdout.read_to_end(&mut bytes).unwrap();
|
|
||||||
bytes
|
|
||||||
});
|
|
||||||
let mut stderr = client.0.stderr.take().unwrap();
|
|
||||||
let stderr = thread::spawn(move || {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
stderr.read_to_end(&mut bytes).unwrap();
|
|
||||||
bytes
|
|
||||||
});
|
|
||||||
let status = client.wait();
|
|
||||||
let stdout = stdout.join().unwrap();
|
|
||||||
let stderr = stderr.join().unwrap();
|
|
||||||
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
|
|
||||||
let stdout = String::from_utf8(stdout).unwrap();
|
|
||||||
let rows: Vec<_> = stdout.lines().collect();
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
assert!(rows.iter().all(|row| row.starts_with('{')
|
|
||||||
&& row.ends_with('}')
|
|
||||||
&& row.contains("\"target\":\"edge\"")));
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"event\":\"session_closed\"")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
#[test]
|
|
||||||
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
|
|
||||||
let secret = "malformed-target-secret-92817";
|
|
||||||
for applet in [
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
"client",
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
"tui",
|
|
||||||
] {
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
|
||||||
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(!output.status.success());
|
|
||||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
||||||
assert!(
|
|
||||||
!stderr.trim().is_empty(),
|
|
||||||
"malformed target must report a diagnostic"
|
|
||||||
);
|
|
||||||
assert!(!stderr.contains(secret), "target authentication key leaked");
|
|
||||||
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn server_arguments_resolve_binds_and_map_policy() {
|
|
||||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
|
||||||
use irtt_app::cmd::server::ServerArgs;
|
|
||||||
use irtt_server::TimestampAllowance;
|
|
||||||
use std::time::Duration;
|
|
||||||
// Isolate zero-argument defaults from the test runner's environment without
|
|
||||||
// mutating process-global environment in this parallel test process.
|
|
||||||
let defaults = ServerArgs::from_arg_matches(
|
|
||||||
&ServerArgs::command()
|
|
||||||
.mut_args(|arg| arg.env(None::<&str>))
|
|
||||||
.try_get_matches_from(["irtt-server"])
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
defaults.resolve_binds(),
|
|
||||||
vec![
|
|
||||||
"[::]:2112".parse().unwrap(),
|
|
||||||
"0.0.0.0:2112".parse().unwrap()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
defaults.server_config(),
|
|
||||||
irtt_server::ServerConfig::default()
|
|
||||||
);
|
|
||||||
let args = ServerArgs::try_parse_from([
|
|
||||||
"irtt-server",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2113",
|
|
||||||
"--bind",
|
|
||||||
"[::1]:2114",
|
|
||||||
"--hmac",
|
|
||||||
"server-cli-test",
|
|
||||||
"--max-sessions",
|
|
||||||
"3",
|
|
||||||
"--max-packet-length",
|
|
||||||
"96",
|
|
||||||
"--min-interval",
|
|
||||||
"25ms",
|
|
||||||
"--burst",
|
|
||||||
"2",
|
|
||||||
"--idle-timeout",
|
|
||||||
"8s",
|
|
||||||
"--max-duration",
|
|
||||||
"1m",
|
|
||||||
"--timestamp-allowance",
|
|
||||||
"single",
|
|
||||||
"--no-dscp",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
args.resolve_binds(),
|
|
||||||
vec![
|
|
||||||
"127.0.0.1:2113".parse().unwrap(),
|
|
||||||
"[::1]:2114".parse().unwrap()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let config = args.server_config();
|
|
||||||
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
|
|
||||||
assert_eq!(config.max_sessions(), 3);
|
|
||||||
assert_eq!(config.max_packet_length(), 96);
|
|
||||||
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
|
|
||||||
assert_eq!(config.burst_allowance(), 2);
|
|
||||||
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
|
|
||||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
|
|
||||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
|
||||||
assert!(!config.dscp_allowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn server_command_line_binds_replace_the_environment_bind() {
|
|
||||||
use std::net::UdpSocket;
|
|
||||||
// Occupied ports give bounded, observable startup failures, without starting
|
|
||||||
// a daemon or needing a process/signal harness merely to inspect arguments.
|
|
||||||
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let environment_addr = environment.local_addr().unwrap().to_string();
|
|
||||||
let explicit_addr = explicit.local_addr().unwrap().to_string();
|
|
||||||
for use_explicit in [false, true] {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
|
|
||||||
command
|
|
||||||
.env_clear()
|
|
||||||
.env("IRTT_SERVER_BIND", &environment_addr)
|
|
||||||
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
|
|
||||||
.args(["--max-sessions", "3"]);
|
|
||||||
if use_explicit {
|
|
||||||
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
|
|
||||||
}
|
|
||||||
let output = command.output().unwrap();
|
|
||||||
assert!(!output.status.success());
|
|
||||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
||||||
let expected = if use_explicit {
|
|
||||||
&explicit_addr
|
|
||||||
} else {
|
|
||||||
&environment_addr
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
stderr.contains(expected),
|
|
||||||
"startup must attempt the selected bind: {stderr}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!stderr.contains("listening on"),
|
|
||||||
"partial startup must not announce listeners"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exercise stdin replacement and EOF through the actual application process.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
|
|
||||||
use std::{
|
|
||||||
io::{BufRead, BufReader, Read, Write},
|
|
||||||
process::Stdio,
|
|
||||||
sync::mpsc,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
|
||||||
.env_clear()
|
|
||||||
.args([
|
|
||||||
"--duration",
|
|
||||||
"0",
|
|
||||||
"--targets-stdin",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
"--format",
|
|
||||||
"jsonl",
|
|
||||||
"--columns",
|
|
||||||
"target,event",
|
|
||||||
])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let stdout = client.0.stdout.take().unwrap();
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let reader = thread::spawn(move || {
|
|
||||||
for line in BufReader::new(stdout).lines() {
|
|
||||||
if tx.send(line.unwrap()).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut stdin = client.0.stdin.take().unwrap();
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
for target in ["first", "second"] {
|
|
||||||
writeln!(stdin, "{target}={}", server.addr).unwrap();
|
|
||||||
stdin.flush().unwrap();
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
let row = rx
|
|
||||||
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
|
||||||
.expect("stdin target set did not produce a reply");
|
|
||||||
let replied = row.contains(&format!("\"target\":\"{target}\""))
|
|
||||||
&& row.contains("\"event\":\"echo_reply\"");
|
|
||||||
rows.push(row);
|
|
||||||
if replied {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Retirement can finish after the newer target's first reply.
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while !rows.iter().any(|row| {
|
|
||||||
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
|
|
||||||
}) {
|
|
||||||
rows.push(
|
|
||||||
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
|
||||||
.expect("superseded stdin target did not retire"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
drop(stdin);
|
|
||||||
let status = client.wait();
|
|
||||||
reader.join().unwrap();
|
|
||||||
rows.extend(rx.try_iter());
|
|
||||||
let mut stderr = String::new();
|
|
||||||
client
|
|
||||||
.0
|
|
||||||
.stderr
|
|
||||||
.take()
|
|
||||||
.unwrap()
|
|
||||||
.read_to_string(&mut stderr)
|
|
||||||
.unwrap();
|
|
||||||
assert!(status.success(), "{stderr}");
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"target\":\"second\"")
|
|
||||||
&& row.contains("\"event\":\"session_closed\"")));
|
|
||||||
}
|
|
||||||
@@ -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(config: ServerConfig) -> 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, config).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_inner(&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_inner();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
Repository-wide guidance in the root `AGENTS.md` also applies here.
|
Repository-wide guidance in the root `AGENTS.md` also applies here.
|
||||||
|
|
||||||
|
## Probe-send commitment API
|
||||||
|
|
||||||
|
`Client::send_probe` and `AsyncClient::send_probe` return
|
||||||
|
`Result<SendReceipt, SendProbeError>`; the Tokio poll driver returns the same
|
||||||
|
result inside `Poll`. A receipt represents an Echo datagram accepted by the
|
||||||
|
socket and already committed in session state: sequence and sent count advanced,
|
||||||
|
and the probe entered pending tracking. It contains `seq`, `remote`, the public
|
||||||
|
post-send `sent_at`, accepted `bytes`, and `send_call`, without schedule metadata.
|
||||||
|
`ClientEvent::from(receipt)` constructs `EchoSent` with no schedule annotations.
|
||||||
|
|
||||||
|
Classify every failure explicitly: `NotCommitted(ClientError)` means no datagram
|
||||||
|
was accepted and no probe committed; `AfterCommit { receipt, source }` preserves
|
||||||
|
the authoritative accepted send when length validation or TX error-queue
|
||||||
|
processing fails. There is no blanket `From<ClientError>` conversion. Do not
|
||||||
|
roll back a committed probe or classify a post-send error with an indiscriminate
|
||||||
|
`?`.
|
||||||
|
|
||||||
|
All fallible state/capacity/counter/allocation and timeout preparation precedes
|
||||||
|
submission. Immediately after `send`/`try_send` returns `Ok(bytes)`, capture the
|
||||||
|
public post-send timestamp, commit infallibly, and construct the receipt before
|
||||||
|
fallible post-send processing. No suspension, readiness wait, fallible allocation,
|
||||||
|
or fallible state operation may intervene between acceptance and commitment;
|
||||||
|
the Tokio poll must never return `Pending` after acceptance.
|
||||||
|
|
||||||
|
The managed driver consumes receipts for cadence/stagger acceptance and constructs
|
||||||
|
`EchoSent` with its scheduled timestamp and timer error. A post-commit failure
|
||||||
|
advances cadence and publishes that send before failure cleanup; the underlying
|
||||||
|
cause remains a sending failure. `NotCommitted` and `Pending` advance neither
|
||||||
|
cadence nor stagger gating. Counters are cumulative accounting, never evidence
|
||||||
|
of one operation's commitment.
|
||||||
|
|
||||||
|
Tokio retains one prepared probe across cancellation/readiness false positives
|
||||||
|
and `WouldBlock`, samples a fresh private anchor on each retry, and clears the
|
||||||
|
prepared probe after commitment even when post-send processing fails. Failed
|
||||||
|
submissions still invalidate kernel TX ID correlation as documented below.
|
||||||
|
|
||||||
## Linux kernel TX timestamp capture
|
## Linux kernel TX timestamp capture
|
||||||
|
|
||||||
On Linux, with the `ancillary` feature, the client best-effort upgrades its
|
On Linux, with the `ancillary` feature, the client best-effort upgrades its
|
||||||
@@ -84,7 +120,9 @@ backlog. `Client`/`AsyncClient` call it after a successful probe send,
|
|||||||
before processing an inbound reply, and before evaluating timeouts; it never
|
before processing an inbound reply, and before evaluating timeouts; it never
|
||||||
waits for a timestamp; an absent timestamp after all of those opportunities
|
waits for a timestamp; an absent timestamp after all of those opportunities
|
||||||
simply stays absent. A genuine `SocketError` record stops the drain and is
|
simply stays absent. A genuine `SocketError` record stops the drain and is
|
||||||
surfaced through the ordinary `ClientError::Socket` path.
|
surfaced as `ClientError::Socket`. During post-send processing it is wrapped in
|
||||||
|
`SendProbeError::AfterCommit` with the accepted send's receipt; receive/timeout
|
||||||
|
operations retain the ordinary `ClientError` path.
|
||||||
|
|
||||||
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
|
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
|
||||||
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
|
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.3"
|
version = "0.7.0"
|
||||||
description = "IRTT-compatible Rust client library"
|
description = "IRTT-compatible Rust client library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||||
categories = ["network-programming"]
|
categories = ["network-programming"]
|
||||||
@@ -15,7 +14,7 @@ categories = ["network-programming"]
|
|||||||
irtt-proto.workspace = true
|
irtt-proto.workspace = true
|
||||||
socket2 = { version = "0.6.5", features = ["all"] }
|
socket2 = { version = "0.6.5", features = ["all"] }
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
|
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
|
||||||
|
|||||||
@@ -26,8 +26,29 @@ binaries are built on.
|
|||||||
negotiated parameters, pending-probe status, and timeout deadlines; callers
|
negotiated parameters, pending-probe status, and timeout deadlines; callers
|
||||||
own cadence and run duration. The managed driver provides fixed cadence,
|
own cadence and run duration. The managed driver provides fixed cadence,
|
||||||
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
|
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
|
||||||
`EchoSent` events leave `scheduled_at` and `timer_error` absent; the managed
|
sends return `Result<SendReceipt, SendProbeError>` instead of event lists.
|
||||||
driver supplies those schedule measurements.
|
A `SendReceipt` carries the committed sequence, remote, post-send timestamp,
|
||||||
|
accepted bytes, and send-call duration. `NotCommitted(ClientError)` guarantees
|
||||||
|
no accepted datagram or committed probe; `AfterCommit { receipt, source }`
|
||||||
|
preserves the accepted send when subsequent validation or TX processing fails.
|
||||||
|
Use `ClientEvent::from(receipt)` for an `EchoSent` event with `scheduled_at` and
|
||||||
|
`timer_error` absent; the managed driver supplies those schedule measurements
|
||||||
|
and publishes committed sends even when post-send processing fails.
|
||||||
|
|
||||||
|
Managed continuous measurement runs can opt into automatic recovery with
|
||||||
|
`ManagedClientConfig.reconnect_timeout: Some(timeout)`. Initial connection
|
||||||
|
failures, closed/failed sessions, server-limited completion, and reply silence
|
||||||
|
while sending trigger independent per-target retries. `reconnect_initial_delay`
|
||||||
|
(default 250 ms) doubles to `reconnect_max_delay` (default 10 s); a valid reply
|
||||||
|
resets the delay. Both delays must be positive and schedulable, with the maximum
|
||||||
|
at least twice the initial delay. Explicit stop and target removal cancel retries.
|
||||||
|
Recovery is disabled by default and is invalid for finite or negotiation-only runs.
|
||||||
|
|
||||||
|
In 0.7, complete `ManagedClientConfig` literals must include the three recovery
|
||||||
|
fields (or use `..Default::default()`), and exhaustive `ManagedConfigError`
|
||||||
|
matches must handle the new recovery-policy variants. A target can emit repeated
|
||||||
|
session-start events within one instance; reset session-bound state, including
|
||||||
|
statistics, for each reopened session. Use `irtt-stats` 0.6 with client 0.7 events.
|
||||||
|
|
||||||
Tokio stays optional: the default build has no runtime and no Tokio
|
Tokio stays optional: the default build has no runtime and no Tokio
|
||||||
dependency at all.
|
dependency at all.
|
||||||
@@ -53,9 +74,17 @@ Configuration is grouped by ownership:
|
|||||||
probe tracking policy.
|
probe tracking policy.
|
||||||
|
|
||||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||||
own ID, endpoint, and authentication inheritance/override; shared
|
own ID, endpoint, address-family override, and authentication inheritance/override; shared
|
||||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||||
|
|
||||||
|
`ManagedTargetConfig.address_family` defaults to `None`, inheriting the shared
|
||||||
|
family policy. `Some(AddressFamily::Ipv4)`, `Some(AddressFamily::Ipv6)`, or
|
||||||
|
`Some(AddressFamily::Any)` overrides it for that target. Every new generation
|
||||||
|
resolves the original endpoint again; callers can constrain a hostname without
|
||||||
|
replacing it with a resolved IP. Changing the family specification replaces
|
||||||
|
that target's generation, including switching between inheritance and an equal
|
||||||
|
explicit override. Hostname expansion and discovery are frontend policies.
|
||||||
|
|
||||||
Blocking receive policy belongs to `Client`:
|
Blocking receive policy belongs to `Client`:
|
||||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||||
|
|||||||
@@ -83,13 +83,16 @@ fn main() {
|
|||||||
}
|
}
|
||||||
if now < end && now >= next_send {
|
if now < end && now >= next_send {
|
||||||
match client.send_probe() {
|
match client.send_probe() {
|
||||||
Ok(events) => {
|
Ok(receipt) => {
|
||||||
events.iter().for_each(print_event);
|
print_event(&receipt.into());
|
||||||
while next_send <= Instant::now() {
|
while next_send <= Instant::now() {
|
||||||
next_send += interval;
|
next_send += interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||||
|
print_event(&(*receipt).into());
|
||||||
|
}
|
||||||
eprintln!("send failed: {err}");
|
eprintln!("send failed: {err}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,13 +121,16 @@ async fn run() {
|
|||||||
|
|
||||||
if Instant::now() < end && Instant::now() >= next_send {
|
if Instant::now() < end && Instant::now() >= next_send {
|
||||||
match client.send_probe().await {
|
match client.send_probe().await {
|
||||||
Ok(events) => {
|
Ok(receipt) => {
|
||||||
events.iter().for_each(print_event);
|
print_event(&receipt.into());
|
||||||
while next_send <= Instant::now() {
|
while next_send <= Instant::now() {
|
||||||
next_send += interval;
|
next_send += interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||||
|
print_event(&(*receipt).into());
|
||||||
|
}
|
||||||
eprintln!("send failed: {err}");
|
eprintln!("send failed: {err}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::{
|
|||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::{echo_sent_event, validate_datagram_length},
|
client::validate_datagram_length,
|
||||||
config::ClientConfig,
|
config::ClientConfig,
|
||||||
error::ClientError,
|
error::ClientError,
|
||||||
event::{ClientEvent, OpenOutcome},
|
event::{ClientEvent, OpenOutcome},
|
||||||
@@ -22,6 +22,7 @@ use crate::{
|
|||||||
socket::{connect_tokio_udp_socket, resolve_remote_tokio},
|
socket::{connect_tokio_udp_socket, resolve_remote_tokio},
|
||||||
socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
|
socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
|
||||||
timing::ClientTimestamp,
|
timing::ClientTimestamp,
|
||||||
|
SendProbeError, SendReceipt,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Maximum amount of immediately available opening work performed by one poll.
|
/// Maximum amount of immediately available opening work performed by one poll.
|
||||||
@@ -92,8 +93,6 @@ pub(crate) struct AsyncOpenState {
|
|||||||
deadline: Option<Instant>,
|
deadline: Option<Instant>,
|
||||||
deadline_timer: Option<Pin<Box<time::Sleep>>>,
|
deadline_timer: Option<Pin<Box<time::Sleep>>>,
|
||||||
request_submitted: bool,
|
request_submitted: bool,
|
||||||
stop_after_current_attempt: bool,
|
|
||||||
stopped_after_current_attempt: bool,
|
|
||||||
buffer: [u8; MAX_OPEN_PACKET_SIZE],
|
buffer: [u8; MAX_OPEN_PACKET_SIZE],
|
||||||
cleanup: Option<AsyncOpenCleanup>,
|
cleanup: Option<AsyncOpenCleanup>,
|
||||||
}
|
}
|
||||||
@@ -105,8 +104,6 @@ impl AsyncOpenState {
|
|||||||
deadline: None,
|
deadline: None,
|
||||||
deadline_timer: None,
|
deadline_timer: None,
|
||||||
request_submitted: false,
|
request_submitted: false,
|
||||||
stop_after_current_attempt: false,
|
|
||||||
stopped_after_current_attempt: false,
|
|
||||||
buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
|
buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
|
||||||
cleanup: None,
|
cleanup: None,
|
||||||
}
|
}
|
||||||
@@ -127,21 +124,12 @@ impl AsyncOpenState {
|
|||||||
self.deadline = None;
|
self.deadline = None;
|
||||||
self.deadline_timer = None;
|
self.deadline_timer = None;
|
||||||
self.request_submitted = false;
|
self.request_submitted = false;
|
||||||
self.stopped_after_current_attempt = self.stop_after_current_attempt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn has_in_flight_work(&self) -> bool {
|
pub(crate) fn has_in_flight_work(&self) -> bool {
|
||||||
self.request_submitted || self.cleanup.is_some()
|
self.request_submitted || self.cleanup.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn request_stop_after_current_attempt(&mut self) {
|
|
||||||
self.stop_after_current_attempt = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stopped_after_current_attempt(&self) -> bool {
|
|
||||||
self.stopped_after_current_attempt
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deadline(&self) -> Instant {
|
fn deadline(&self) -> Instant {
|
||||||
self.deadline
|
self.deadline
|
||||||
.expect("an open attempt has an active deadline")
|
.expect("an open attempt has an active deadline")
|
||||||
@@ -253,8 +241,13 @@ impl AsyncClient {
|
|||||||
///
|
///
|
||||||
/// A prepared packet is retained across readiness false positives,
|
/// A prepared packet is retained across readiness false positives,
|
||||||
/// cancellation, and socket errors so a later call retries the same logical
|
/// cancellation, and socket errors so a later call retries the same logical
|
||||||
/// probe without advancing state before kernel acceptance.
|
/// probe without advancing state before kernel acceptance. Cancellation or
|
||||||
pub async fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
/// `WouldBlock` before submission is uncommitted. After acceptance there is
|
||||||
|
/// no suspension before infallible commitment, and the prepared probe is
|
||||||
|
/// cleared even if post-send processing returns [`SendProbeError::AfterCommit`].
|
||||||
|
/// That failure retains the authoritative receipt; earlier failures return
|
||||||
|
/// [`SendProbeError::NotCommitted`].
|
||||||
|
pub async fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||||
poll_fn(|cx| self.poll_send_probe(cx)).await
|
poll_fn(|cx| self.poll_send_probe(cx)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,10 +311,6 @@ impl AsyncClient {
|
|||||||
if state.attempt >= attempt_count {
|
if state.attempt >= attempt_count {
|
||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||||
}
|
}
|
||||||
if state.stop_after_current_attempt && state.deadline.is_none() {
|
|
||||||
state.stopped_after_current_attempt = true;
|
|
||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
|
||||||
}
|
|
||||||
if state.deadline.is_none() {
|
if state.deadline.is_none() {
|
||||||
let timeout = self.machine.config().open.timeouts[state.attempt];
|
let timeout = self.machine.config().open.timeouts[state.attempt];
|
||||||
if let Err(error) = state.start_attempt(timeout) {
|
if let Err(error) = state.start_attempt(timeout) {
|
||||||
@@ -362,9 +351,6 @@ impl AsyncClient {
|
|||||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||||
Poll::Ready(Ok(false)) => {
|
Poll::Ready(Ok(false)) => {
|
||||||
state.finish_attempt();
|
state.finish_attempt();
|
||||||
if state.stopped_after_current_attempt() {
|
|
||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Poll::Ready(Ok(true)) => {}
|
Poll::Ready(Ok(true)) => {}
|
||||||
@@ -390,9 +376,6 @@ impl AsyncClient {
|
|||||||
};
|
};
|
||||||
if datagram.received_at.mono > state.deadline() {
|
if datagram.received_at.mono > state.deadline() {
|
||||||
state.finish_attempt();
|
state.finish_attempt();
|
||||||
if state.stopped_after_current_attempt() {
|
|
||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,14 +419,14 @@ impl AsyncClient {
|
|||||||
pub(crate) fn poll_send_probe(
|
pub(crate) fn poll_send_probe(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &mut Context<'_>,
|
cx: &mut Context<'_>,
|
||||||
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
|
) -> Poll<Result<SendReceipt, SendProbeError>> {
|
||||||
if let Err(error) = self.machine.ensure_open() {
|
if let Err(error) = self.machine.ensure_open() {
|
||||||
return Poll::Ready(Err(error));
|
return Poll::Ready(Err(SendProbeError::NotCommitted(error)));
|
||||||
}
|
}
|
||||||
if self.prepared_probe.is_none() {
|
if self.prepared_probe.is_none() {
|
||||||
match self.machine.prepare_probe() {
|
match self.machine.prepare_probe() {
|
||||||
Ok(prepared) => self.prepared_probe = Some(prepared),
|
Ok(prepared) => self.prepared_probe = Some(prepared),
|
||||||
Err(error) => return Poll::Ready(Err(error)),
|
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
@@ -451,7 +434,9 @@ impl AsyncClient {
|
|||||||
Poll::Pending => return Poll::Pending,
|
Poll::Pending => return Poll::Pending,
|
||||||
Poll::Ready(Ok(())) => {}
|
Poll::Ready(Ok(())) => {}
|
||||||
Poll::Ready(Err(error)) => {
|
Poll::Ready(Err(error)) => {
|
||||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||||
|
error,
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,15 +446,8 @@ impl AsyncClient {
|
|||||||
.expect("prepared probe was retained across readiness");
|
.expect("prepared probe was retained across readiness");
|
||||||
let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
|
let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
|
||||||
Ok(preflight) => preflight,
|
Ok(preflight) => preflight,
|
||||||
Err(error) => return Poll::Ready(Err(error)),
|
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||||
};
|
};
|
||||||
let mut events = Vec::new();
|
|
||||||
if let Err(source) = events.try_reserve(1) {
|
|
||||||
return Poll::Ready(Err(ClientError::AllocationFailed {
|
|
||||||
operation: "probe event result",
|
|
||||||
source,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let expected_bytes = prepared.bytes.len();
|
let expected_bytes = prepared.bytes.len();
|
||||||
|
|
||||||
// Private pre-send anchor: finalizes all fallible commit work
|
// Private pre-send anchor: finalizes all fallible commit work
|
||||||
@@ -483,7 +461,7 @@ impl AsyncClient {
|
|||||||
.finalize_probe_commit(machine_preflight, send_anchor)
|
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||||
{
|
{
|
||||||
Ok(commit) => commit,
|
Ok(commit) => commit,
|
||||||
Err(error) => return Poll::Ready(Err(error)),
|
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||||
};
|
};
|
||||||
let send_call_start = Instant::now();
|
let send_call_start = Instant::now();
|
||||||
let send_result = self.socket.try_send(&prepared.bytes);
|
let send_result = self.socket.try_send(&prepared.bytes);
|
||||||
@@ -497,7 +475,9 @@ impl AsyncClient {
|
|||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.machine.invalidate_kernel_tx_correlation();
|
self.machine.invalidate_kernel_tx_correlation();
|
||||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||||
|
error,
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let send_finished_at = Instant::now();
|
let send_finished_at = Instant::now();
|
||||||
@@ -512,14 +492,26 @@ impl AsyncClient {
|
|||||||
self.prepared_probe = None;
|
self.prepared_probe = None;
|
||||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||||
|
|
||||||
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
|
let receipt = SendReceipt {
|
||||||
return Poll::Ready(Err(error));
|
seq: sent.seq,
|
||||||
|
remote: self.remote,
|
||||||
|
sent_at: sent.sent_at,
|
||||||
|
bytes: sent.bytes,
|
||||||
|
send_call,
|
||||||
|
};
|
||||||
|
if let Err(source) = validate_datagram_length(expected_bytes, bytes) {
|
||||||
|
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||||
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if let Err(error) = self.drain_tx_timestamps() {
|
if let Err(source) = self.drain_tx_timestamps() {
|
||||||
return Poll::Ready(Err(error));
|
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||||
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
events.push(echo_sent_event(self.remote, sent, send_call));
|
return Poll::Ready(Ok(receipt));
|
||||||
return Poll::Ready(Ok(events));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +755,16 @@ impl AsyncClient {
|
|||||||
.map_err(ClientError::Socket)
|
.map_err(ClientError::Socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel opening without waiting for a reply or socket writability.
|
||||||
|
/// Retained post-token cleanup gets one best-effort nonblocking send.
|
||||||
|
pub(crate) fn cancel_open(&self, state: &mut AsyncOpenState) -> Option<ClientError> {
|
||||||
|
let cleanup = state.cleanup.take()?;
|
||||||
|
if let Some(packet) = cleanup.packet() {
|
||||||
|
let _ = self.socket.try_send(packet);
|
||||||
|
}
|
||||||
|
Some(cleanup.into_primary())
|
||||||
|
}
|
||||||
|
|
||||||
fn poll_open_cleanup(
|
fn poll_open_cleanup(
|
||||||
&self,
|
&self,
|
||||||
state: &mut AsyncOpenState,
|
state: &mut AsyncOpenState,
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ use crate::{
|
|||||||
event::{ClientEvent, OpenOutcome},
|
event::{ClientEvent, OpenOutcome},
|
||||||
receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
|
receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
|
||||||
session::machine::{
|
session::machine::{
|
||||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, ProbeSent,
|
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, SessionMachine,
|
||||||
SessionMachine, MAX_OPEN_PACKET_SIZE,
|
MAX_OPEN_PACKET_SIZE,
|
||||||
},
|
},
|
||||||
socket::{connect_udp_socket, resolve_remote},
|
socket::{connect_udp_socket, resolve_remote},
|
||||||
socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
|
socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
|
||||||
timing::ClientTimestamp,
|
timing::ClientTimestamp,
|
||||||
|
SendProbeError, SendReceipt,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -193,18 +194,19 @@ impl Client {
|
|||||||
/// Send one echo probe now. The caller owns pacing and run duration.
|
/// Send one echo probe now. The caller owns pacing and run duration.
|
||||||
///
|
///
|
||||||
/// The negotiated interval and duration do not gate low-level sends.
|
/// The negotiated interval and duration do not gate low-level sends.
|
||||||
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
/// A receipt means socket acceptance and infallible session commitment have
|
||||||
|
/// completed. Failures before acceptance return [`SendProbeError::NotCommitted`];
|
||||||
|
/// later validation or TX processing failures preserve the receipt in
|
||||||
|
/// [`SendProbeError::AfterCommit`].
|
||||||
|
pub fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||||
let remote = self.remote;
|
let remote = self.remote;
|
||||||
let (runtime, socket) = (&mut self.runtime, &self.socket);
|
let (runtime, socket) = (&mut self.runtime, &self.socket);
|
||||||
let prepared = runtime.prepare_probe()?;
|
let prepared = runtime
|
||||||
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
|
.prepare_probe()
|
||||||
let mut events = Vec::new();
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
events
|
let machine_preflight = runtime
|
||||||
.try_reserve(1)
|
.preflight_probe_commit(&prepared)
|
||||||
.map_err(|source| ClientError::AllocationFailed {
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
operation: "probe event result",
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let expected_bytes = prepared.bytes.len();
|
let expected_bytes = prepared.bytes.len();
|
||||||
|
|
||||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||||
@@ -212,14 +214,16 @@ impl Client {
|
|||||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||||
// the public measurement `sent_at` captured further below.
|
// the public measurement `sent_at` captured further below.
|
||||||
let send_anchor = ClientTimestamp::now();
|
let send_anchor = ClientTimestamp::now();
|
||||||
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
|
let machine_commit = runtime
|
||||||
|
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||||
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
let send_call_start = Instant::now();
|
let send_call_start = Instant::now();
|
||||||
|
|
||||||
let bytes = match socket.send(&prepared.bytes) {
|
let bytes = match socket.send(&prepared.bytes) {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
runtime.invalidate_kernel_tx_correlation();
|
runtime.invalidate_kernel_tx_correlation();
|
||||||
return Err(ClientError::Socket(error));
|
return Err(SendProbeError::NotCommitted(ClientError::Socket(error)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let send_finished_at = Instant::now();
|
let send_finished_at = Instant::now();
|
||||||
@@ -231,11 +235,26 @@ impl Client {
|
|||||||
let sent_at = ClientTimestamp::now();
|
let sent_at = ClientTimestamp::now();
|
||||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||||
validate_datagram_length(expected_bytes, bytes)?;
|
let receipt = SendReceipt {
|
||||||
self.drain_tx_timestamps()?;
|
seq: sent.seq,
|
||||||
|
remote,
|
||||||
|
sent_at: sent.sent_at,
|
||||||
|
bytes: sent.bytes,
|
||||||
|
send_call,
|
||||||
|
};
|
||||||
|
validate_datagram_length(expected_bytes, bytes).map_err(|source| {
|
||||||
|
SendProbeError::AfterCommit {
|
||||||
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
self.drain_tx_timestamps()
|
||||||
|
.map_err(|source| SendProbeError::AfterCommit {
|
||||||
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
})?;
|
||||||
|
|
||||||
events.push(echo_sent_event(remote, sent, send_call));
|
Ok(receipt)
|
||||||
Ok(events)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive and classify at most one datagram from the socket.
|
/// Receive and classify at most one datagram from the socket.
|
||||||
@@ -604,22 +623,6 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn echo_sent_event(
|
|
||||||
remote: SocketAddr,
|
|
||||||
sent: ProbeSent,
|
|
||||||
send_call: Duration,
|
|
||||||
) -> ClientEvent {
|
|
||||||
ClientEvent::EchoSent {
|
|
||||||
seq: sent.seq,
|
|
||||||
remote,
|
|
||||||
scheduled_at: None,
|
|
||||||
sent_at: sent.sent_at,
|
|
||||||
bytes: sent.bytes,
|
|
||||||
send_call,
|
|
||||||
timer_error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
|
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
|
||||||
if actual == expected {
|
if actual == expected {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
||||||
//! adapter for a caller that owns a runtime and drives socket readiness
|
//! adapter for a caller that owns a runtime and drives socket readiness
|
||||||
//! itself. Both adapters send one probe whenever called; callers own cadence
|
//! itself. Both adapters send one probe whenever called; callers own cadence
|
||||||
//! and run duration, using `negotiation()` after Open. [`managed`] owns
|
//! and run duration, using `negotiation()` after Open. Sends return a
|
||||||
|
//! [`SendReceipt`] after socket acceptance and session commitment;
|
||||||
|
//! [`SendProbeError`] distinguishes uncommitted failures from post-commit
|
||||||
|
//! failures carrying that receipt. Convert a receipt to [`ClientEvent`] for
|
||||||
|
//! presentation or statistics. [`managed`] owns
|
||||||
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
|
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
|
||||||
//! `ManagedClientTask` /
|
//! `ManagedClientTask` /
|
||||||
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
||||||
@@ -43,6 +47,9 @@
|
|||||||
//!
|
//!
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
/// Cargo package version of this compiled library.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
#[cfg(feature = "tokio")]
|
#[cfg(feature = "tokio")]
|
||||||
mod async_client;
|
mod async_client;
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -54,6 +61,7 @@ pub mod managed;
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod probe;
|
mod probe;
|
||||||
mod receive;
|
mod receive;
|
||||||
|
mod send;
|
||||||
mod session;
|
mod session;
|
||||||
mod socket;
|
mod socket;
|
||||||
mod socket_options;
|
mod socket_options;
|
||||||
@@ -73,5 +81,6 @@ pub use event::{
|
|||||||
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
|
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
|
||||||
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
|
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
|
pub use send::{SendProbeError, SendReceipt};
|
||||||
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
||||||
pub use timing::ClientTimestamp;
|
pub use timing::ClientTimestamp;
|
||||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
|
||||||
|
|
||||||
use super::TargetId;
|
use super::TargetId;
|
||||||
|
|
||||||
@@ -48,11 +48,16 @@ impl TargetAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Endpoint and authentication specification for one managed target.
|
/// Endpoint, address-family and authentication specification for one managed target.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedTargetConfig {
|
pub struct ManagedTargetConfig {
|
||||||
pub id: TargetId,
|
pub id: TargetId,
|
||||||
pub server_addr: String,
|
pub server_addr: String,
|
||||||
|
/// Override the shared address-family policy. `None` inherits it.
|
||||||
|
///
|
||||||
|
/// The endpoint remains a hostname when supplied as one, so every new
|
||||||
|
/// generation resolves it again using the effective family policy.
|
||||||
|
pub address_family: Option<AddressFamily>,
|
||||||
pub auth: TargetAuth,
|
pub auth: TargetAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,7 @@ impl ManagedTargetConfig {
|
|||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
server_addr: server_addr.into(),
|
server_addr: server_addr.into(),
|
||||||
|
address_family: None,
|
||||||
auth: TargetAuth::Inherit,
|
auth: TargetAuth::Inherit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +92,7 @@ pub enum ManagedCompletionPolicy {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedClientConfig {
|
pub struct ManagedClientConfig {
|
||||||
/// Reusable session/socket configuration shared across target generations.
|
/// Reusable session/socket configuration shared across target generations.
|
||||||
/// Each target supplies its endpoint and resolves its authentication policy.
|
/// Each target supplies its endpoint and resolves its family and authentication policies.
|
||||||
pub client: ClientConfig,
|
pub client: ClientConfig,
|
||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
pub completion: ManagedCompletionPolicy,
|
pub completion: ManagedCompletionPolicy,
|
||||||
@@ -95,6 +101,15 @@ pub struct ManagedClientConfig {
|
|||||||
pub outcome_history_limit: usize,
|
pub outcome_history_limit: usize,
|
||||||
pub max_live_target_generations: usize,
|
pub max_live_target_generations: usize,
|
||||||
pub final_drain: Duration,
|
pub final_drain: Duration,
|
||||||
|
/// Retry failed/closed sessions and detect silence while sending. Only valid
|
||||||
|
/// for continuous measurement runs. None (the default) disables reconnection.
|
||||||
|
pub reconnect_timeout: Option<Duration>,
|
||||||
|
/// First retry delay, also restored after a valid reply (default: 250 ms).
|
||||||
|
/// Must be positive and at most half of `reconnect_max_delay`.
|
||||||
|
pub reconnect_initial_delay: Duration,
|
||||||
|
/// Cap for per-target exponential backoff with a fixed multiplier of two
|
||||||
|
/// (default: 10 seconds). Must be schedulable from the current instant.
|
||||||
|
pub reconnect_max_delay: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ManagedClientConfig {
|
impl Default for ManagedClientConfig {
|
||||||
@@ -108,6 +123,9 @@ impl Default for ManagedClientConfig {
|
|||||||
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
|
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
|
||||||
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
|
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
|
||||||
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
|
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
|
||||||
|
reconnect_timeout: None,
|
||||||
|
reconnect_initial_delay: Duration::from_millis(250),
|
||||||
|
reconnect_max_delay: Duration::from_secs(10),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +159,12 @@ pub struct ManagedTargetStatus {
|
|||||||
pub target: TargetInstance,
|
pub target: TargetInstance,
|
||||||
pub desired: bool,
|
pub desired: bool,
|
||||||
pub lifecycle: ManagedTargetLifecycle,
|
pub lifecycle: ManagedTargetLifecycle,
|
||||||
|
/// Authoritative completed accounting for this target generation.
|
||||||
|
///
|
||||||
|
/// Present if and only if `lifecycle` is [`ManagedTargetLifecycle::Terminal`].
|
||||||
|
/// This shares the terminal phase's outcome with [`ManagedEvent::TargetFinished`],
|
||||||
|
/// so terminal details remain durable even when presentation events are lost.
|
||||||
|
pub outcome: Option<Arc<ManagedTargetOutcome>>,
|
||||||
pub server_addr: Arc<str>,
|
pub server_addr: Arc<str>,
|
||||||
pub remote: Option<SocketAddr>,
|
pub remote: Option<SocketAddr>,
|
||||||
}
|
}
|
||||||
@@ -149,6 +173,10 @@ pub struct ManagedTargetStatus {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedStatus {
|
pub struct ManagedStatus {
|
||||||
pub lifecycle: ManagedLifecycle,
|
pub lifecycle: ManagedLifecycle,
|
||||||
|
/// Whether the task durably observed an explicit stop request.
|
||||||
|
///
|
||||||
|
/// This remains false until the task observes the request latch. Natural
|
||||||
|
/// stopping and terminality alone do not imply a stop request was observed.
|
||||||
pub stop_requested: bool,
|
pub stop_requested: bool,
|
||||||
pub applied_command_sequence: u64,
|
pub applied_command_sequence: u64,
|
||||||
pub desired_target_count: usize,
|
pub desired_target_count: usize,
|
||||||
@@ -165,9 +193,21 @@ pub struct ManagedStatus {
|
|||||||
pub discarded_target_outcomes: u64,
|
pub discarded_target_outcomes: u64,
|
||||||
pub targets: Arc<[ManagedTargetStatus]>,
|
pub targets: Arc<[ManagedTargetStatus]>,
|
||||||
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
||||||
|
/// Authoritative final accounting, present if and only if `lifecycle` is
|
||||||
|
/// [`ManagedLifecycle::Completed`] or [`ManagedLifecycle::Failed`].
|
||||||
|
///
|
||||||
|
/// Shares the terminal task phase's outcome with [`ManagedEvent::Completed`]
|
||||||
|
/// or [`ManagedEvent::Failed`]. Abandoned deliberately has no outcome.
|
||||||
pub final_outcome: Option<Arc<ManagedOutcome>>,
|
pub final_outcome: Option<Arc<ManagedOutcome>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receiving half of authoritative durable latest-state observation.
|
||||||
|
///
|
||||||
|
/// The current snapshot is immediately readable. Updates may coalesce, and the
|
||||||
|
/// final snapshot remains available after the sender closes at task termination.
|
||||||
|
/// Use [`ManagedEventSubscription`] for lossy presentation events.
|
||||||
|
pub type ManagedStatusSubscription = tokio::sync::watch::Receiver<Arc<ManagedStatus>>;
|
||||||
|
|
||||||
/// Lossy presentation event emitted by the managed task.
|
/// Lossy presentation event emitted by the managed task.
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -336,6 +376,13 @@ pub struct ManagedTargetFailure {
|
|||||||
/// Configuration failure detected before any runtime work begins.
|
/// Configuration failure detected before any runtime work begins.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ManagedConfigError {
|
pub enum ManagedConfigError {
|
||||||
|
#[error("reconnection requires continuous measurement mode and a positive, schedulable silence timeout")]
|
||||||
|
InvalidReconnectPolicy,
|
||||||
|
#[error("reconnection delays must be positive and schedulable, with maximum delay at least twice the initial delay (initial: {initial:?}, maximum: {maximum:?})")]
|
||||||
|
InvalidReconnectDelays {
|
||||||
|
initial: Duration,
|
||||||
|
maximum: Duration,
|
||||||
|
},
|
||||||
#[error("FinishWhenQuiescent requires at least one initial target")]
|
#[error("FinishWhenQuiescent requires at least one initial target")]
|
||||||
EmptyInitialTargets,
|
EmptyInitialTargets,
|
||||||
#[error("duplicate managed target id {id}")]
|
#[error("duplicate managed target id {id}")]
|
||||||
|
|||||||
+148
-2007
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,547 @@
|
|||||||
|
//! Task construction, public control/observation handles, and stop/update admission.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashSet, VecDeque},
|
||||||
|
fmt,
|
||||||
|
future::Future,
|
||||||
|
pin::Pin,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
task::{Context, Poll},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::sync::{broadcast, mpsc, oneshot, watch};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
managed::{
|
||||||
|
ManagedClientConfig, ManagedCommandAcknowledgement, ManagedCommandApplyError,
|
||||||
|
ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedEvent,
|
||||||
|
ManagedEventSubscription, ManagedLifecycle, ManagedStatus, ManagedStatusSubscription,
|
||||||
|
ManagedSubscribeError, ManagedTargetConfig, ManagedTargetLifecycle, ManagedTargetStatus,
|
||||||
|
TargetInstance,
|
||||||
|
},
|
||||||
|
session::machine::SessionMachine,
|
||||||
|
socket_options::validate_ttl,
|
||||||
|
ClientConfig, ClientError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
arm_wake,
|
||||||
|
target::{TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||||
|
timeouts::TIMEOUT_WORK_BUDGET,
|
||||||
|
ManagedClientTask, OutcomeHistory, TaskPhase, TaskResources,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
|
||||||
|
// power of two; Tokio does not expose it as a public constant.
|
||||||
|
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
|
||||||
|
|
||||||
|
/// Entry point for constructing a unified Tokio managed task.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// Construct the task and its separate control handle, subscribe before
|
||||||
|
/// starting the task, then run it under the caller's Tokio runtime. A reachable
|
||||||
|
/// server is required at runtime, so this example only type-checks.
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use irtt_client::{ClientConfig, ClientEvent};
|
||||||
|
/// use irtt_client::managed::{
|
||||||
|
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let config = ManagedClientConfig {
|
||||||
|
/// client: ClientConfig {
|
||||||
|
/// request: irtt_client::SessionRequest {
|
||||||
|
/// duration: Some(std::time::Duration::from_secs(10)),
|
||||||
|
/// ..irtt_client::SessionRequest::default()
|
||||||
|
/// },
|
||||||
|
/// ..ClientConfig::default()
|
||||||
|
/// },
|
||||||
|
/// ..ManagedClientConfig::default()
|
||||||
|
/// };
|
||||||
|
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
|
||||||
|
/// let (task, handle) = ManagedClient::task(config, targets)?;
|
||||||
|
/// let mut events = handle.subscribe()?;
|
||||||
|
/// let driver = tokio::spawn(task);
|
||||||
|
///
|
||||||
|
/// while let Ok(event) = events.recv().await {
|
||||||
|
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
|
||||||
|
/// println!("received a probe reply");
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// let outcome = driver.await?;
|
||||||
|
/// println!("run ended: {:?}", outcome.end_reason);
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ManagedClient;
|
||||||
|
|
||||||
|
impl ManagedClient {
|
||||||
|
/// Validate configuration and construct a runtime-independent task/handle pair.
|
||||||
|
pub fn task(
|
||||||
|
config: ManagedClientConfig,
|
||||||
|
targets: Vec<ManagedTargetConfig>,
|
||||||
|
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||||
|
build_task(config, targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct StopSignal {
|
||||||
|
requested: AtomicBool,
|
||||||
|
update_admission: AtomicU8,
|
||||||
|
wake: watch::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub(super) enum UpdateAdmission {
|
||||||
|
Open,
|
||||||
|
Stopping,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAdmission {
|
||||||
|
fn from_raw(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
value if value == Self::Open as u8 => Self::Open,
|
||||||
|
value if value == Self::Stopping as u8 => Self::Stopping,
|
||||||
|
value if value == Self::Closed as u8 => Self::Closed,
|
||||||
|
_ => unreachable!("managed update admission stores a known state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StopSignal {
|
||||||
|
fn request(&self) {
|
||||||
|
self.begin_stopping();
|
||||||
|
if !self.requested.swap(true, Ordering::AcqRel) {
|
||||||
|
self.wake.send_replace(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_admission(&self) -> UpdateAdmission {
|
||||||
|
UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_stopping(&self) {
|
||||||
|
let mut current = self.update_admission.load(Ordering::Acquire);
|
||||||
|
loop {
|
||||||
|
match UpdateAdmission::from_raw(current) {
|
||||||
|
UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
|
||||||
|
UpdateAdmission::Open as u8,
|
||||||
|
UpdateAdmission::Stopping as u8,
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
) {
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(observed) => current = observed,
|
||||||
|
},
|
||||||
|
UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn close_updates(&self) {
|
||||||
|
self.update_admission
|
||||||
|
.store(UpdateAdmission::Closed as u8, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_requested(&self) -> bool {
|
||||||
|
self.requested.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloneable control and observation capability for a managed task.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ManagedClientHandle {
|
||||||
|
stop: Arc<StopSignal>,
|
||||||
|
commands: mpsc::Sender<ManagedCommand>,
|
||||||
|
max_live_target_generations: usize,
|
||||||
|
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||||
|
events: broadcast::WeakSender<ManagedEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ManagedClientHandle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ManagedClientHandle")
|
||||||
|
.field("status", &self.status())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedClientHandle {
|
||||||
|
/// Return the latest durable immutable status snapshot.
|
||||||
|
pub fn status(&self) -> Arc<ManagedStatus> {
|
||||||
|
Arc::clone(&self.status.borrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to authoritative durable latest-state observation.
|
||||||
|
///
|
||||||
|
/// The current snapshot is marked as seen when subscribing.
|
||||||
|
/// The current snapshot is immediately available through `borrow()`. Updates
|
||||||
|
/// may coalesce; this is not a lossless event log. After the task terminates,
|
||||||
|
/// the sender closes but the final snapshot remains readable. Use
|
||||||
|
/// [`Self::subscribe`] for the lossy presentation-event stream.
|
||||||
|
pub fn subscribe_status(&self) -> ManagedStatusSubscription {
|
||||||
|
let mut status = self.status.clone();
|
||||||
|
drop(status.borrow_and_update());
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to future lossy presentation events.
|
||||||
|
pub fn subscribe(&self) -> Result<ManagedEventSubscription, ManagedSubscribeError> {
|
||||||
|
self.events
|
||||||
|
.upgrade()
|
||||||
|
.map(|sender| sender.subscribe())
|
||||||
|
.ok_or(ManagedSubscribeError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request idempotent graceful stop and return a durable-status receipt.
|
||||||
|
pub fn stop(&self) -> ManagedStopReceipt {
|
||||||
|
self.stop.request();
|
||||||
|
ManagedStopReceipt::new(self.subscribe_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
|
||||||
|
match self.stop.update_admission() {
|
||||||
|
UpdateAdmission::Open => {}
|
||||||
|
UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
|
||||||
|
UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
|
||||||
|
}
|
||||||
|
if targets.len() > self.max_live_target_generations {
|
||||||
|
return Err(ManagedCommandError::TooManyTargets {
|
||||||
|
configured: targets.len(),
|
||||||
|
limit: self.max_live_target_generations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observing `Open` only permits attempting submission. A successful
|
||||||
|
// `try_send` transfers command ownership to the driver. A concurrent
|
||||||
|
// stop may move admission to `Stopping` before this send, allowing the
|
||||||
|
// command to enqueue; the driver-side check immediately before
|
||||||
|
// `apply_targets` determines whether it applies or resolves as
|
||||||
|
// `Stopping`. Terminal sealing closes the receiver before draining
|
||||||
|
// accepted commands.
|
||||||
|
let (acknowledgement, receiver) = oneshot::channel();
|
||||||
|
match self.commands.try_send(ManagedCommand::UpdateTargets {
|
||||||
|
targets,
|
||||||
|
acknowledgement,
|
||||||
|
}) {
|
||||||
|
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
|
||||||
|
UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
|
||||||
|
UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
|
||||||
|
UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
|
||||||
|
},
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receipt for a target-set transaction accepted by the driver queue.
|
||||||
|
#[must_use = "await the receipt to observe target-update application"]
|
||||||
|
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> {
|
||||||
|
match Pin::new(&mut self.receiver).poll(cx) {
|
||||||
|
Poll::Ready(Ok(result)) => Poll::Ready(result),
|
||||||
|
Poll::Ready(Err(_)) => {
|
||||||
|
Poll::Ready(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||||
|
}
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum ManagedCommand {
|
||||||
|
UpdateTargets {
|
||||||
|
targets: Vec<ManagedTargetConfig>,
|
||||||
|
acknowledgement:
|
||||||
|
oneshot::Sender<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receipt resolving once stop is durably observed or the task is terminal.
|
||||||
|
///
|
||||||
|
/// Resolves from authoritative status as soon as `stop_requested` is true or
|
||||||
|
/// the lifecycle is Completed, Failed, or Abandoned. It need not wait for
|
||||||
|
/// graceful cleanup after stop observation.
|
||||||
|
#[must_use = "await the receipt to observe durable stop acknowledgement"]
|
||||||
|
pub struct ManagedStopReceipt {
|
||||||
|
future: Pin<Box<dyn Future<Output = ()> + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedStopReceipt {
|
||||||
|
fn new(mut status: ManagedStatusSubscription) -> Self {
|
||||||
|
let future = Box::pin(async move {
|
||||||
|
while !{
|
||||||
|
let snapshot = status.borrow();
|
||||||
|
snapshot.stop_requested
|
||||||
|
|| matches!(
|
||||||
|
snapshot.lifecycle,
|
||||||
|
ManagedLifecycle::Completed
|
||||||
|
| ManagedLifecycle::Failed
|
||||||
|
| ManagedLifecycle::Abandoned
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
// A closed sender retains the terminal snapshot. Never wait
|
||||||
|
// indefinitely if the channel closes.
|
||||||
|
if status.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self { future }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Future for ManagedStopReceipt {
|
||||||
|
type Output = ();
|
||||||
|
|
||||||
|
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||||
|
self.future.as_mut().poll(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_task(
|
||||||
|
config: ManagedClientConfig,
|
||||||
|
targets: Vec<ManagedTargetConfig>,
|
||||||
|
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||||
|
if config.event_capacity == 0 {
|
||||||
|
return Err(ManagedConfigError::ZeroEventCapacity);
|
||||||
|
}
|
||||||
|
if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
|
||||||
|
return Err(ManagedConfigError::EventCapacityTooLarge {
|
||||||
|
configured: config.event_capacity,
|
||||||
|
maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.command_capacity == 0 {
|
||||||
|
return Err(ManagedConfigError::ZeroCommandCapacity);
|
||||||
|
}
|
||||||
|
if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
|
||||||
|
return Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||||
|
configured: config.command_capacity,
|
||||||
|
maximum: tokio::sync::Semaphore::MAX_PERMITS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.reconnect_initial_delay.is_zero()
|
||||||
|
|| config.reconnect_max_delay.is_zero()
|
||||||
|
|| config
|
||||||
|
.reconnect_initial_delay
|
||||||
|
.checked_mul(2)
|
||||||
|
.is_none_or(|minimum| config.reconnect_max_delay < minimum)
|
||||||
|
// Tokio represents timer deadlines in u64 milliseconds, reserving the
|
||||||
|
// top two values for state. Leave room for its millisecond rounding.
|
||||||
|
|| config.reconnect_max_delay.as_millis() >= u128::from(u64::MAX - 2)
|
||||||
|
|| config
|
||||||
|
.reconnect_max_delay
|
||||||
|
.checked_add(std::time::Duration::from_millis(1))
|
||||||
|
.and_then(|delay| Instant::now().checked_add(delay))
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(ManagedConfigError::InvalidReconnectDelays {
|
||||||
|
initial: config.reconnect_initial_delay,
|
||||||
|
maximum: config.reconnect_max_delay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(timeout) = config.reconnect_timeout {
|
||||||
|
if timeout.is_zero()
|
||||||
|
|| Instant::now().checked_add(timeout).is_none()
|
||||||
|
|| config.client.request.duration.is_some()
|
||||||
|
|| config.client.request.run_mode != crate::RunMode::Normal
|
||||||
|
{
|
||||||
|
return Err(ManagedConfigError::InvalidReconnectPolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.max_live_target_generations == 0 {
|
||||||
|
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
||||||
|
}
|
||||||
|
if Instant::now().checked_add(config.final_drain).is_none() {
|
||||||
|
return Err(ManagedConfigError::UnschedulableFinalDrain {
|
||||||
|
duration: config.final_drain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if targets.len() > config.max_live_target_generations {
|
||||||
|
return Err(ManagedConfigError::TooManyTargets {
|
||||||
|
configured: targets.len(),
|
||||||
|
limit: config.max_live_target_generations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if targets.is_empty() && config.completion == ManagedCompletionPolicy::FinishWhenQuiescent {
|
||||||
|
return Err(ManagedConfigError::EmptyInitialTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ids = HashSet::with_capacity(targets.len());
|
||||||
|
let mut runtimes = Vec::with_capacity(targets.len());
|
||||||
|
let mut next_generation = 1_u64;
|
||||||
|
for target in targets {
|
||||||
|
if !ids.insert(target.id.clone()) {
|
||||||
|
return Err(ManagedConfigError::DuplicateTargetId { id: target.id });
|
||||||
|
}
|
||||||
|
let generation = next_generation;
|
||||||
|
next_generation = next_generation
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||||
|
let mut client_config = config.client.clone();
|
||||||
|
client_config.address_family = target
|
||||||
|
.address_family
|
||||||
|
.unwrap_or(config.client.address_family);
|
||||||
|
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||||
|
validate_target_config(&client_config).map_err(|source| {
|
||||||
|
ManagedConfigError::InvalidTarget {
|
||||||
|
id: target.id.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
runtimes.push(TargetRuntime {
|
||||||
|
instance: TargetInstance {
|
||||||
|
id: target.id.clone(),
|
||||||
|
generation,
|
||||||
|
},
|
||||||
|
config: target.clone(),
|
||||||
|
membership: TargetMembership::Desired,
|
||||||
|
server_addr: Arc::from(target.server_addr),
|
||||||
|
remote: None,
|
||||||
|
counters: TargetCounters::default(),
|
||||||
|
sent_before_session: 0,
|
||||||
|
retry_delay: config.reconnect_initial_delay,
|
||||||
|
silence_since: None,
|
||||||
|
last_reply_at: None,
|
||||||
|
phase: Some(TargetPhase::Pending { client_config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (event_sender, _) = broadcast::channel(config.event_capacity);
|
||||||
|
let weak_events = event_sender.downgrade();
|
||||||
|
let (wake_sender, wake_receiver) = watch::channel(());
|
||||||
|
let stop = Arc::new(StopSignal {
|
||||||
|
requested: AtomicBool::new(false),
|
||||||
|
update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
|
||||||
|
wake: wake_sender,
|
||||||
|
});
|
||||||
|
let history = OutcomeHistory::new(config.outcome_history_limit);
|
||||||
|
let initial_targets = runtimes
|
||||||
|
.iter()
|
||||||
|
.map(|target| ManagedTargetStatus {
|
||||||
|
target: target.instance.clone(),
|
||||||
|
desired: target.membership.is_desired(),
|
||||||
|
lifecycle: ManagedTargetLifecycle::Pending,
|
||||||
|
outcome: None,
|
||||||
|
server_addr: Arc::clone(&target.server_addr),
|
||||||
|
remote: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let phase = TaskPhase::NotStarted;
|
||||||
|
let initial = Arc::new(ManagedStatus {
|
||||||
|
lifecycle: phase.lifecycle(),
|
||||||
|
stop_requested: false,
|
||||||
|
applied_command_sequence: 0,
|
||||||
|
desired_target_count: runtimes.len(),
|
||||||
|
connecting_target_count: 0,
|
||||||
|
opening_target_count: 0,
|
||||||
|
active_target_count: 0,
|
||||||
|
draining_target_count: 0,
|
||||||
|
closing_target_count: 0,
|
||||||
|
terminal_target_count: 0,
|
||||||
|
total_target_outcomes: 0,
|
||||||
|
successful_target_outcomes: 0,
|
||||||
|
failed_target_outcomes: 0,
|
||||||
|
peer_closed_target_outcomes: 0,
|
||||||
|
discarded_target_outcomes: 0,
|
||||||
|
targets: Arc::from(initial_targets.into_boxed_slice()),
|
||||||
|
recent_target_outcomes: history.recent(),
|
||||||
|
final_outcome: phase.final_outcome(),
|
||||||
|
});
|
||||||
|
let (status_sender, status_receiver) = watch::channel(initial);
|
||||||
|
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
|
||||||
|
let max_live_target_generations = config.max_live_target_generations;
|
||||||
|
let resources = TaskResources {
|
||||||
|
status: status_sender,
|
||||||
|
events: Some(event_sender),
|
||||||
|
stop: Arc::clone(&stop),
|
||||||
|
};
|
||||||
|
let task = ManagedClientTask {
|
||||||
|
#[cfg(test)]
|
||||||
|
timeout_inspections: std::cell::RefCell::new(Vec::new()),
|
||||||
|
phase,
|
||||||
|
config,
|
||||||
|
targets: runtimes,
|
||||||
|
commands: command_receiver,
|
||||||
|
history,
|
||||||
|
resources: Some(resources),
|
||||||
|
wake: Some(arm_wake(wake_receiver)),
|
||||||
|
timer: None,
|
||||||
|
cursor: 0,
|
||||||
|
timeout_cursor: 0,
|
||||||
|
timeout_backlog: VecDeque::with_capacity(TIMEOUT_WORK_BUDGET),
|
||||||
|
scan_remaining: 0,
|
||||||
|
send_cursor: 0,
|
||||||
|
burst_remaining: 0,
|
||||||
|
stagger_remaining: 0,
|
||||||
|
send_gate: None,
|
||||||
|
last_stagger_send: None,
|
||||||
|
stop_observed: false,
|
||||||
|
next_generation,
|
||||||
|
applied_command_sequence: 0,
|
||||||
|
};
|
||||||
|
let handle = ManagedClientHandle {
|
||||||
|
stop,
|
||||||
|
commands: command_sender,
|
||||||
|
max_live_target_generations,
|
||||||
|
status: status_receiver,
|
||||||
|
events: weak_events,
|
||||||
|
};
|
||||||
|
Ok((task, handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_target_config(config: &ClientConfig) -> Result<(), ClientError> {
|
||||||
|
config.open.validate()?;
|
||||||
|
SessionMachine::validate_config(config)?;
|
||||||
|
if let Some(ttl) = config.socket.ttl {
|
||||||
|
validate_ttl(ttl)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//! Burst/stagger send selection and committed-send cadence.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
task::{Context, Poll},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
managed::{schedule::instant_abs_diff, ManagedTargetEndReason, ManagedTargetFailurePhase},
|
||||||
|
ClientEvent, SendProbeError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
target::{OpenSessionFailureCleanup, TargetPhase},
|
||||||
|
ManagedClientTask, TARGET_WORK_BUDGET,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl ManagedClientTask {
|
||||||
|
pub(super) fn active_count(&self) -> usize {
|
||||||
|
self.targets
|
||||||
|
.iter()
|
||||||
|
.filter(|target| target.is_paced_active())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_stagger_spacing(&self) -> Option<Duration> {
|
||||||
|
let mut active = 0;
|
||||||
|
let mut minimum: Option<Duration> = None;
|
||||||
|
for target in &self.targets {
|
||||||
|
let TargetPhase::Active { schedule, .. } = target.phase() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !target.membership.is_desired() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active += 1;
|
||||||
|
minimum = minimum.into_iter().chain(Some(schedule.interval())).min();
|
||||||
|
}
|
||||||
|
minimum.map(|interval| stagger_spacing(interval, active))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stagger_target_added(&mut self, now: Instant) {
|
||||||
|
let Some(existing) = self.send_gate.filter(|gate| *gate > now) else {
|
||||||
|
self.send_gate = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let candidate = self
|
||||||
|
.last_stagger_send
|
||||||
|
.zip(self.active_stagger_spacing())
|
||||||
|
.and_then(|(last, spacing)| last.checked_add(spacing))
|
||||||
|
.filter(|gate| *gate > now);
|
||||||
|
self.send_gate = candidate.map(|candidate| existing.min(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stagger_target_removed(&mut self, now: Instant) {
|
||||||
|
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next stagger gate after a send was accepted at `accepted_at`.
|
||||||
|
///
|
||||||
|
/// The gate advances on its **own** cadence rather than being re-anchored
|
||||||
|
/// on every observed accept time. Anchoring on `accepted_at` folds each
|
||||||
|
/// send's wakeup latency into the next slot and then into every slot after
|
||||||
|
/// it, so the gate — and with it the whole group's send cadence — drifts
|
||||||
|
/// later without bound while the per-target schedules stay on their
|
||||||
|
/// absolute grid. The drift is not hypothetical: it shows up as a
|
||||||
|
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
|
||||||
|
/// interval, as probe slots the schedule then skips.
|
||||||
|
///
|
||||||
|
/// `accepted_at` is still the anchor in the two cases where continuing the
|
||||||
|
/// old cadence would be wrong: there is no previous gate, or the pacer has
|
||||||
|
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
|
||||||
|
/// stranded in the past from releasing a burst of catch-up sends once the
|
||||||
|
/// group starts moving again.
|
||||||
|
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
|
||||||
|
let base = match self.send_gate {
|
||||||
|
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
|
||||||
|
_ => accepted_at,
|
||||||
|
};
|
||||||
|
base.checked_add(spacing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_stagger_acceptance(
|
||||||
|
&mut self,
|
||||||
|
result: SendResult,
|
||||||
|
stagger_spacing: Option<Duration>,
|
||||||
|
accepted_at: Instant,
|
||||||
|
) {
|
||||||
|
if !result.accepted() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(spacing) = stagger_spacing else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.last_stagger_send = Some(accepted_at);
|
||||||
|
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_one_send(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
now: Instant,
|
||||||
|
stagger_spacing: Option<Duration>,
|
||||||
|
) -> SendResult {
|
||||||
|
if !self.targets[index].membership.is_desired() {
|
||||||
|
if let TargetPhase::Active { send_waiting, .. } = self.targets[index].phase_mut() {
|
||||||
|
*send_waiting = false;
|
||||||
|
}
|
||||||
|
return SendResult::NotAttempted;
|
||||||
|
}
|
||||||
|
let phase = self.targets[index].take_phase();
|
||||||
|
let TargetPhase::Active {
|
||||||
|
mut client,
|
||||||
|
mut schedule,
|
||||||
|
mut send_waiting,
|
||||||
|
} = phase
|
||||||
|
else {
|
||||||
|
self.targets[index].restore_phase(phase);
|
||||||
|
return SendResult::NotAttempted;
|
||||||
|
};
|
||||||
|
// A burst pass may span several slots; validate this target against
|
||||||
|
// fresh time rather than the pass's earlier scheduling snapshot.
|
||||||
|
let now = now.max(Instant::now());
|
||||||
|
if client
|
||||||
|
.next_probe_timeout_deadline()
|
||||||
|
.is_some_and(|deadline| deadline <= now)
|
||||||
|
{
|
||||||
|
send_waiting = false;
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client,
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
});
|
||||||
|
return SendResult::NotAttempted;
|
||||||
|
}
|
||||||
|
if !schedule.permit_probe_at(now) {
|
||||||
|
send_waiting = false;
|
||||||
|
let pending = client.has_pending_probes();
|
||||||
|
if !pending {
|
||||||
|
self.targets[index].sync_packets_sent(&client);
|
||||||
|
}
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client,
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
});
|
||||||
|
if pending {
|
||||||
|
return SendResult::NotAttempted;
|
||||||
|
}
|
||||||
|
self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||||
|
return SendResult::Ready { accepted: false };
|
||||||
|
}
|
||||||
|
if schedule
|
||||||
|
.next_send_deadline()
|
||||||
|
.is_none_or(|deadline| deadline > now)
|
||||||
|
{
|
||||||
|
send_waiting = false;
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client,
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
});
|
||||||
|
return SendResult::NotAttempted;
|
||||||
|
}
|
||||||
|
let commit =
|
||||||
|
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
|
||||||
|
Ok(commit) => commit,
|
||||||
|
Err(error) => {
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client,
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
});
|
||||||
|
self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Timing,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Drain,
|
||||||
|
);
|
||||||
|
return SendResult::Failed { accepted: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let scheduled_at = commit.scheduled_at;
|
||||||
|
let result = client.poll_send_probe(cx);
|
||||||
|
let receipt = match &result {
|
||||||
|
Poll::Ready(Ok(receipt))
|
||||||
|
| Poll::Ready(Err(SendProbeError::AfterCommit { receipt, .. })) => Some(*receipt),
|
||||||
|
Poll::Pending | Poll::Ready(Err(SendProbeError::NotCommitted(_))) => None,
|
||||||
|
};
|
||||||
|
let accepted = receipt.is_some();
|
||||||
|
let send_result = match &result {
|
||||||
|
Poll::Pending => SendResult::Pending,
|
||||||
|
Poll::Ready(Ok(_)) => SendResult::Ready { accepted },
|
||||||
|
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
|
||||||
|
};
|
||||||
|
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
|
||||||
|
if accepted {
|
||||||
|
schedule.commit(commit);
|
||||||
|
}
|
||||||
|
let schedule_error = if accepted {
|
||||||
|
schedule.skip_missed_slots_at(Instant::now()).err()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.targets[index].sync_packets_sent(&client);
|
||||||
|
send_waiting = result.is_pending();
|
||||||
|
if send_waiting {
|
||||||
|
// A blocked sender is not evidence of an unresponsive peer. Arm
|
||||||
|
// a fresh silence period only after transmission resumes.
|
||||||
|
self.targets[index].silence_since = None;
|
||||||
|
self.targets[index].last_reply_at = None;
|
||||||
|
}
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client,
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
});
|
||||||
|
// A committed send must reach the lifecycle/accounting stream even
|
||||||
|
// when post-send processing fails. Publish before failure cleanup.
|
||||||
|
let sent_event = receipt.map(|receipt| {
|
||||||
|
let mut event = ClientEvent::from(receipt);
|
||||||
|
if let ClientEvent::EchoSent {
|
||||||
|
scheduled_at: intended,
|
||||||
|
timer_error,
|
||||||
|
..
|
||||||
|
} = &mut event
|
||||||
|
{
|
||||||
|
*intended = Some(scheduled_at);
|
||||||
|
*timer_error = Some(instant_abs_diff(receipt.sent_at.mono, scheduled_at));
|
||||||
|
}
|
||||||
|
event
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Poll::Pending => SendResult::Pending,
|
||||||
|
Poll::Ready(Ok(_)) => {
|
||||||
|
self.publish_client_events(index, vec![sent_event.unwrap()]);
|
||||||
|
if let Some(error) = schedule_error {
|
||||||
|
self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Timing,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Drain,
|
||||||
|
);
|
||||||
|
SendResult::Failed { accepted }
|
||||||
|
} else {
|
||||||
|
SendResult::Ready { accepted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
if let Some(event) = sent_event {
|
||||||
|
self.publish_client_events(index, vec![event]);
|
||||||
|
}
|
||||||
|
let error = match error {
|
||||||
|
SendProbeError::NotCommitted(source) => source,
|
||||||
|
SendProbeError::AfterCommit { source, .. } => *source,
|
||||||
|
};
|
||||||
|
self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Sending,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Drain,
|
||||||
|
);
|
||||||
|
SendResult::Failed { accepted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn poll_staggered_send(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||||
|
if self.active_count() == 0 || self.send_gate.is_some_and(|gate| gate > now) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.stagger_remaining == 0 {
|
||||||
|
self.stagger_remaining = self.targets.len();
|
||||||
|
}
|
||||||
|
let work = self.stagger_remaining.min(TARGET_WORK_BUDGET);
|
||||||
|
for _ in 0..work {
|
||||||
|
let index = self.send_cursor % self.targets.len();
|
||||||
|
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||||
|
self.stagger_remaining -= 1;
|
||||||
|
if !self.targets[index].is_paced_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stagger_spacing = self.active_stagger_spacing();
|
||||||
|
let result = self.poll_one_send(index, cx, now, stagger_spacing);
|
||||||
|
if result == SendResult::NotAttempted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.stagger_remaining = 0;
|
||||||
|
return matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||||
|
}
|
||||||
|
self.stagger_remaining > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn poll_burst_sends(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||||
|
if self.targets.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.burst_remaining == 0 {
|
||||||
|
self.burst_remaining = self.targets.len();
|
||||||
|
}
|
||||||
|
let work = self.burst_remaining.min(TARGET_WORK_BUDGET);
|
||||||
|
let mut immediate = false;
|
||||||
|
for _ in 0..work {
|
||||||
|
let index = self.send_cursor % self.targets.len();
|
||||||
|
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||||
|
self.burst_remaining -= 1;
|
||||||
|
let result = self.poll_one_send(index, cx, now, None);
|
||||||
|
immediate |= matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||||
|
}
|
||||||
|
immediate || self.burst_remaining > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release gate delays a send opportunity; it cannot create one.
|
||||||
|
pub(super) fn gated_send_deadline(
|
||||||
|
cadence: Option<Instant>,
|
||||||
|
gate: Option<Instant>,
|
||||||
|
) -> Option<Instant> {
|
||||||
|
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
enum SendResult {
|
||||||
|
NotAttempted,
|
||||||
|
Pending,
|
||||||
|
Ready { accepted: bool },
|
||||||
|
Failed { accepted: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendResult {
|
||||||
|
fn accepted(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Ready { accepted: true } | Self::Failed { accepted: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stagger_spacing(interval: Duration, active_targets: usize) -> Duration {
|
||||||
|
let divisor = u128::try_from(active_targets.max(1)).unwrap_or(u128::MAX);
|
||||||
|
let nanos = (interval.as_nanos() / divisor).max(1);
|
||||||
|
let seconds = u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX);
|
||||||
|
let subsec = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
|
||||||
|
Duration::new(seconds, subsec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_gate_only_delays_an_existing_send_deadline() {
|
||||||
|
let earlier = Instant::now();
|
||||||
|
let later = earlier + Duration::from_secs(1);
|
||||||
|
for (cadence, gate, expected) in [
|
||||||
|
(None, None, None),
|
||||||
|
(None, Some(earlier), None),
|
||||||
|
(None, Some(later), None),
|
||||||
|
(Some(earlier), None, Some(earlier)),
|
||||||
|
(Some(later), Some(earlier), Some(later)),
|
||||||
|
(Some(earlier), Some(earlier), Some(earlier)),
|
||||||
|
(Some(earlier), Some(later), Some(later)),
|
||||||
|
] {
|
||||||
|
assert_eq!(gated_send_deadline(cadence, gate), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
//! Target-generation state, phase transitions, accounting, and completion.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
async_client::{AsyncClient, AsyncOpenState},
|
||||||
|
managed::schedule::ProbeSchedule,
|
||||||
|
managed::{
|
||||||
|
classify_client_error, ManagedEvent, ManagedTargetConfig, ManagedTargetEndReason,
|
||||||
|
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase,
|
||||||
|
ManagedTargetLifecycle, ManagedTargetOutcome, TargetInstance,
|
||||||
|
},
|
||||||
|
ClientConfig, ClientError, ClientEvent, OpenOutcome,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{ManagedClientTask, TARGET_WORK_BUDGET};
|
||||||
|
|
||||||
|
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
||||||
|
|
||||||
|
type ConnectFuture =
|
||||||
|
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||||
|
|
||||||
|
pub(super) enum TargetPhase {
|
||||||
|
Retrying {
|
||||||
|
deadline: Instant,
|
||||||
|
},
|
||||||
|
Pending {
|
||||||
|
client_config: ClientConfig,
|
||||||
|
},
|
||||||
|
Connecting {
|
||||||
|
future: ConnectFuture,
|
||||||
|
},
|
||||||
|
Opening {
|
||||||
|
client: AsyncClient,
|
||||||
|
open: Box<AsyncOpenState>,
|
||||||
|
},
|
||||||
|
Active {
|
||||||
|
client: AsyncClient,
|
||||||
|
schedule: ProbeSchedule,
|
||||||
|
send_waiting: bool,
|
||||||
|
},
|
||||||
|
Draining {
|
||||||
|
client: AsyncClient,
|
||||||
|
drain_started_at: Instant,
|
||||||
|
deadline: Instant,
|
||||||
|
primary_end: ManagedTargetEndReason,
|
||||||
|
cleanup_failure: Option<ManagedTargetFailure>,
|
||||||
|
post_deadline_receives_remaining: usize,
|
||||||
|
},
|
||||||
|
Closing {
|
||||||
|
client: AsyncClient,
|
||||||
|
deadline: Instant,
|
||||||
|
primary_end: ManagedTargetEndReason,
|
||||||
|
cleanup_failure: Option<ManagedTargetFailure>,
|
||||||
|
},
|
||||||
|
/// Terminal state always owns its finalized authoritative outcome.
|
||||||
|
Terminal {
|
||||||
|
outcome: Arc<ManagedTargetOutcome>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetPhase {
|
||||||
|
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
|
||||||
|
match self {
|
||||||
|
Self::Retrying { .. } => ManagedTargetLifecycle::Pending,
|
||||||
|
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
|
||||||
|
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
|
||||||
|
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
|
||||||
|
Self::Active { .. } => ManagedTargetLifecycle::Active,
|
||||||
|
Self::Draining { .. } => ManagedTargetLifecycle::Draining,
|
||||||
|
Self::Closing { .. } => ManagedTargetLifecycle::Closing,
|
||||||
|
Self::Terminal { .. } => ManagedTargetLifecycle::Terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn client(&self) -> &AsyncClient {
|
||||||
|
match self {
|
||||||
|
Self::Opening { client, .. }
|
||||||
|
| Self::Active { client, .. }
|
||||||
|
| Self::Draining { client, .. }
|
||||||
|
| Self::Closing { client, .. } => client,
|
||||||
|
_ => panic!("managed target has no client in this phase"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_client(self) -> AsyncClient {
|
||||||
|
match self {
|
||||||
|
Self::Opening { client, .. }
|
||||||
|
| Self::Active { client, .. }
|
||||||
|
| Self::Draining { client, .. }
|
||||||
|
| Self::Closing { client, .. } => client,
|
||||||
|
_ => panic!("managed target has no client in this phase"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct TargetCounters {
|
||||||
|
packets_sent: u64,
|
||||||
|
/// Unique echo replies that arrived in order and before their probe timed
|
||||||
|
/// out.
|
||||||
|
///
|
||||||
|
/// A reply that arrives behind an already-received sequence, or after its
|
||||||
|
/// own probe timed out, is counted by `late` and deliberately not here.
|
||||||
|
/// These counters are durable, unlike the lossy managed presentation
|
||||||
|
/// events.
|
||||||
|
replies_received: u64,
|
||||||
|
duplicates: u64,
|
||||||
|
late: u64,
|
||||||
|
warning_events: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetCounters {
|
||||||
|
/// Account for one client event.
|
||||||
|
///
|
||||||
|
/// Events that carry no durable target-local count are ignored. Probe
|
||||||
|
/// sends are not counted here: `packets_sent` is authoritative in the
|
||||||
|
/// underlying client and is synchronized through
|
||||||
|
/// [`TargetRuntime::sync_packets_sent`].
|
||||||
|
fn observe(&mut self, event: &ClientEvent) {
|
||||||
|
match event {
|
||||||
|
ClientEvent::EchoReply { .. } => {
|
||||||
|
self.replies_received = self.replies_received.saturating_add(1);
|
||||||
|
}
|
||||||
|
ClientEvent::DuplicateReply { .. } => {
|
||||||
|
self.duplicates = self.duplicates.saturating_add(1);
|
||||||
|
}
|
||||||
|
ClientEvent::LateReply { .. } => {
|
||||||
|
self.late = self.late.saturating_add(1);
|
||||||
|
}
|
||||||
|
ClientEvent::Warning { .. } => {
|
||||||
|
self.warning_events = self.warning_events.saturating_add(1);
|
||||||
|
}
|
||||||
|
ClientEvent::EchoSent { .. }
|
||||||
|
| ClientEvent::EchoLoss { .. }
|
||||||
|
| ClientEvent::SessionStarted(_)
|
||||||
|
| ClientEvent::NoTestCompleted(_)
|
||||||
|
| ClientEvent::SessionClosed { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) enum RetirementReason {
|
||||||
|
Removed,
|
||||||
|
Replaced,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RetirementReason> for ManagedTargetEndReason {
|
||||||
|
fn from(reason: RetirementReason) -> Self {
|
||||||
|
match reason {
|
||||||
|
RetirementReason::Removed => Self::Removed,
|
||||||
|
RetirementReason::Replaced => Self::Replaced,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum TargetMembership {
|
||||||
|
Desired,
|
||||||
|
Withdrawn(RetirementReason),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetMembership {
|
||||||
|
pub(super) fn is_desired(&self) -> bool {
|
||||||
|
matches!(self, Self::Desired)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retirement_reason(&self) -> Option<RetirementReason> {
|
||||||
|
match self {
|
||||||
|
Self::Desired => None,
|
||||||
|
Self::Withdrawn(reason) => Some(*reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct TargetRuntime {
|
||||||
|
pub(super) instance: TargetInstance,
|
||||||
|
pub(super) config: ManagedTargetConfig,
|
||||||
|
pub(super) membership: TargetMembership,
|
||||||
|
pub(super) server_addr: Arc<str>,
|
||||||
|
pub(super) remote: Option<std::net::SocketAddr>,
|
||||||
|
pub(super) counters: TargetCounters,
|
||||||
|
pub(super) sent_before_session: u64,
|
||||||
|
pub(super) retry_delay: Duration,
|
||||||
|
pub(super) silence_since: Option<Instant>,
|
||||||
|
pub(super) last_reply_at: Option<Instant>,
|
||||||
|
// None only while a synchronous operation owns the phase locally.
|
||||||
|
pub(super) phase: Option<TargetPhase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetRuntime {
|
||||||
|
pub(super) fn silence_deadline(&self, timeout: Option<Duration>) -> Option<Instant> {
|
||||||
|
let TargetPhase::Active {
|
||||||
|
schedule,
|
||||||
|
send_waiting,
|
||||||
|
..
|
||||||
|
} = self.phase()
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if *send_waiting || schedule.is_finished() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.silence_since?.checked_add(timeout?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn phase(&self) -> &TargetPhase {
|
||||||
|
self.phase
|
||||||
|
.as_ref()
|
||||||
|
.expect("managed target phase is extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn phase_mut(&mut self) -> &mut TargetPhase {
|
||||||
|
self.phase
|
||||||
|
.as_mut()
|
||||||
|
.expect("managed target phase is extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn take_phase(&mut self) -> TargetPhase {
|
||||||
|
self.phase
|
||||||
|
.take()
|
||||||
|
.expect("managed target phase is extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_phase(&mut self, phase: TargetPhase) {
|
||||||
|
assert!(
|
||||||
|
self.phase.is_none(),
|
||||||
|
"managed target phase is already installed"
|
||||||
|
);
|
||||||
|
self.phase = Some(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_paced_active(&self) -> bool {
|
||||||
|
let phase = self.phase();
|
||||||
|
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine the current client's authoritative sent count with retired
|
||||||
|
/// sessions, so one target generation keeps durable accounting on retries.
|
||||||
|
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
|
||||||
|
self.counters.packets_sent = self
|
||||||
|
.sent_before_session
|
||||||
|
.saturating_add(client.packets_sent());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_live_packets_sent(&mut self) {
|
||||||
|
self.counters.packets_sent = self
|
||||||
|
.sent_before_session
|
||||||
|
.saturating_add(self.phase().client().packets_sent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build this target's durable outcome.
|
||||||
|
pub(super) fn outcome(
|
||||||
|
&self,
|
||||||
|
end_reason: ManagedTargetEndReason,
|
||||||
|
cleanup_failure: Option<ManagedTargetFailure>,
|
||||||
|
) -> ManagedTargetOutcome {
|
||||||
|
ManagedTargetOutcome {
|
||||||
|
target: self.instance.clone(),
|
||||||
|
server_addr: Arc::clone(&self.server_addr),
|
||||||
|
remote: self.remote,
|
||||||
|
end_reason,
|
||||||
|
packets_sent: self.counters.packets_sent,
|
||||||
|
replies_received: self.counters.replies_received,
|
||||||
|
duplicates: self.counters.duplicates,
|
||||||
|
late: self.counters.late,
|
||||||
|
warning_events: self.counters.warning_events,
|
||||||
|
cleanup_failure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) enum OpenSessionFailureCleanup {
|
||||||
|
Drain,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedClientTask {
|
||||||
|
pub(super) fn install_target_phase(&mut self, index: usize, phase: TargetPhase) {
|
||||||
|
let previous = self.targets[index].phase().lifecycle();
|
||||||
|
self.targets[index].phase = Some(phase);
|
||||||
|
self.publish_target_phase(index, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_target_phase(&mut self, index: usize, previous: ManagedTargetLifecycle) {
|
||||||
|
let lifecycle = self.targets[index].phase().lifecycle();
|
||||||
|
let desired = self.targets[index].membership.is_desired();
|
||||||
|
let was_paced_active = desired && previous == ManagedTargetLifecycle::Active;
|
||||||
|
let paced_active = self.targets[index].is_paced_active();
|
||||||
|
let now = Instant::now();
|
||||||
|
match (was_paced_active, paced_active) {
|
||||||
|
(false, true) => self.stagger_target_added(now),
|
||||||
|
(true, false) => self.stagger_target_removed(now),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.replace_status();
|
||||||
|
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||||
|
target: self.targets[index].instance.clone(),
|
||||||
|
lifecycle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
|
||||||
|
for event in events {
|
||||||
|
match &event {
|
||||||
|
ClientEvent::EchoSent { sent_at, .. } => {
|
||||||
|
let target = &mut self.targets[index];
|
||||||
|
let anchor = target
|
||||||
|
.last_reply_at
|
||||||
|
.filter(|reply| {
|
||||||
|
self.config.reconnect_timeout.is_some_and(|timeout| {
|
||||||
|
sent_at.mono.duration_since(*reply) < timeout
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(sent_at.mono);
|
||||||
|
target.silence_since.get_or_insert(anchor);
|
||||||
|
}
|
||||||
|
ClientEvent::EchoReply { received_at, .. }
|
||||||
|
| ClientEvent::LateReply { received_at, .. }
|
||||||
|
| ClientEvent::DuplicateReply { received_at, .. } => {
|
||||||
|
// A reply disarms silence until another probe is committed.
|
||||||
|
// A healthy sender with a long interval can remain idle.
|
||||||
|
self.targets[index].silence_since = None;
|
||||||
|
self.targets[index].last_reply_at = Some(received_at.mono);
|
||||||
|
self.targets[index].retry_delay = self.config.reconnect_initial_delay;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.targets[index].counters.observe(&event);
|
||||||
|
self.publish_event(ManagedEvent::Client {
|
||||||
|
target: self.targets[index].instance.clone(),
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_target(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
mut end_reason: ManagedTargetEndReason,
|
||||||
|
cleanup_failure: Option<ManagedTargetFailure>,
|
||||||
|
) {
|
||||||
|
if self.can_reconnect(index)
|
||||||
|
&& matches!(
|
||||||
|
end_reason,
|
||||||
|
ManagedTargetEndReason::Failed(_)
|
||||||
|
| ManagedTargetEndReason::PeerClosed
|
||||||
|
| ManagedTargetEndReason::TestComplete
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let target = &mut self.targets[index];
|
||||||
|
target.sent_before_session = target.counters.packets_sent;
|
||||||
|
target.silence_since = None;
|
||||||
|
target.last_reply_at = None;
|
||||||
|
if let Some(deadline) = Instant::now().checked_add(target.retry_delay) {
|
||||||
|
target.retry_delay = target
|
||||||
|
.retry_delay
|
||||||
|
.checked_mul(2)
|
||||||
|
.unwrap_or(self.config.reconnect_max_delay)
|
||||||
|
.min(self.config.reconnect_max_delay);
|
||||||
|
self.install_target_phase(index, TargetPhase::Retrying { deadline });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A delay validated at construction may no longer fit after a long run.
|
||||||
|
end_reason = ManagedTargetEndReason::Failed(duration_overflow_failure());
|
||||||
|
}
|
||||||
|
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
|
||||||
|
// Finalize history before the phase publisher installs Terminal, updates
|
||||||
|
// pacing and publishes one complete status followed by the lifecycle event.
|
||||||
|
self.history.record(outcome.as_ref().clone());
|
||||||
|
self.install_target_phase(
|
||||||
|
index,
|
||||||
|
TargetPhase::Terminal {
|
||||||
|
outcome: Arc::clone(&outcome),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.publish_event(ManagedEvent::TargetFinished { outcome });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_reconnect(&self, index: usize) -> bool {
|
||||||
|
self.config.reconnect_timeout.is_some()
|
||||||
|
&& !self.phase.is_stopping()
|
||||||
|
&& self.targets[index].membership.is_desired()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
|
||||||
|
let failure = classify_client_error(phase, &error);
|
||||||
|
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_open_session_failure(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
phase: ManagedTargetFailurePhase,
|
||||||
|
error: ClientError,
|
||||||
|
now: Instant,
|
||||||
|
cleanup: OpenSessionFailureCleanup,
|
||||||
|
) -> bool {
|
||||||
|
let primary_end = ManagedTargetEndReason::Failed(classify_client_error(phase, &error));
|
||||||
|
let (TargetPhase::Opening { client, .. } | TargetPhase::Active { client, .. }) =
|
||||||
|
self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!("open-session failure requires an opening or active target")
|
||||||
|
};
|
||||||
|
client.discard_prepared_probe();
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
if self.can_reconnect(index) {
|
||||||
|
return self.begin_close(index, primary_end, None, now);
|
||||||
|
}
|
||||||
|
match cleanup {
|
||||||
|
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
|
||||||
|
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn effective_retirement(&self, index: usize) -> Option<ManagedTargetEndReason> {
|
||||||
|
self.targets[index]
|
||||||
|
.membership
|
||||||
|
.retirement_reason()
|
||||||
|
.map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_connecting(&mut self, index: usize) {
|
||||||
|
let TargetPhase::Pending { client_config } = self.targets[index].take_phase() else {
|
||||||
|
unreachable!("connecting starts from a pending target")
|
||||||
|
};
|
||||||
|
let endpoint = Arc::clone(&self.targets[index].server_addr);
|
||||||
|
let future = Box::pin(async move { AsyncClient::connect(endpoint, client_config).await });
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Connecting { future });
|
||||||
|
self.publish_target_phase(index, ManagedTargetLifecycle::Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn poll_target(&mut self, index: usize, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||||
|
let retirement = self.effective_retirement(index);
|
||||||
|
let stopping = self.phase.is_stopping() || retirement.is_some();
|
||||||
|
match self.targets[index].phase_mut() {
|
||||||
|
TargetPhase::Retrying { deadline } => {
|
||||||
|
if stopping {
|
||||||
|
self.finish_target(
|
||||||
|
index,
|
||||||
|
retirement.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
false
|
||||||
|
} else if now >= *deadline {
|
||||||
|
let target = &self.targets[index].config;
|
||||||
|
let mut client_config = self.config.client.clone();
|
||||||
|
client_config.address_family = target
|
||||||
|
.address_family
|
||||||
|
.unwrap_or(client_config.address_family);
|
||||||
|
client_config.auth = target.auth.resolve(&client_config.auth);
|
||||||
|
self.install_target_phase(index, TargetPhase::Pending { client_config });
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Pending { .. } => {
|
||||||
|
if stopping {
|
||||||
|
self.finish_target(
|
||||||
|
index,
|
||||||
|
retirement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.start_connecting(index);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Connecting { future } => {
|
||||||
|
if stopping {
|
||||||
|
self.finish_target(
|
||||||
|
index,
|
||||||
|
retirement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match future.as_mut().poll(cx) {
|
||||||
|
Poll::Pending => false,
|
||||||
|
Poll::Ready(Ok(client)) => {
|
||||||
|
self.targets[index].remote = Some(client.remote_addr());
|
||||||
|
self.install_target_phase(
|
||||||
|
index,
|
||||||
|
TargetPhase::Opening {
|
||||||
|
client,
|
||||||
|
open: Box::new(AsyncOpenState::new()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
self.fail_target(index, ManagedTargetFailurePhase::Connecting, error);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Opening { client, open } => {
|
||||||
|
if stopping {
|
||||||
|
let retirement = retirement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(ManagedTargetEndReason::Stopped);
|
||||||
|
let cleanup_failure = client.cancel_open(open).map(|error| {
|
||||||
|
classify_client_error(ManagedTargetFailurePhase::Opening, &error)
|
||||||
|
});
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
self.finish_target(index, retirement, cleanup_failure);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match client.poll_open(open, cx) {
|
||||||
|
Poll::Pending => false,
|
||||||
|
Poll::Ready(Ok(OpenOutcome::Started(started))) => {
|
||||||
|
let schedule = match ProbeSchedule::new(
|
||||||
|
started.at.mono,
|
||||||
|
&started.negotiation.accepted,
|
||||||
|
) {
|
||||||
|
Ok(schedule) => schedule,
|
||||||
|
Err(error) => {
|
||||||
|
return self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Timing,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.publish_client_events(
|
||||||
|
index,
|
||||||
|
vec![ClientEvent::SessionStarted(started)],
|
||||||
|
);
|
||||||
|
let previous = self.targets[index].take_phase();
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Active {
|
||||||
|
client: previous.into_client(),
|
||||||
|
schedule,
|
||||||
|
send_waiting: false,
|
||||||
|
});
|
||||||
|
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
|
||||||
|
self.publish_client_events(
|
||||||
|
index,
|
||||||
|
vec![ClientEvent::NoTestCompleted(completed)],
|
||||||
|
);
|
||||||
|
self.finish_target(index, ManagedTargetEndReason::NoTestComplete, None);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Active { client, .. } => {
|
||||||
|
if stopping {
|
||||||
|
client.discard_prepared_probe();
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
if self.config.reconnect_timeout.is_some() {
|
||||||
|
return self.begin_close(
|
||||||
|
index,
|
||||||
|
retirement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self.begin_drain(
|
||||||
|
index,
|
||||||
|
retirement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if client
|
||||||
|
.next_probe_timeout_deadline()
|
||||||
|
.is_some_and(|deadline| deadline <= now)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let received = match client.poll_recv(cx) {
|
||||||
|
Poll::Pending => false,
|
||||||
|
Poll::Ready(Ok(events)) => {
|
||||||
|
self.publish_client_events(index, events);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
return self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Receiving,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Close,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.targets[index].phase().client().is_peer_closed() {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.targets[index]
|
||||||
|
.silence_deadline(self.config.reconnect_timeout)
|
||||||
|
.is_some_and(|deadline| deadline <= now)
|
||||||
|
{
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
return self.begin_close(
|
||||||
|
index,
|
||||||
|
ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||||
|
phase: ManagedTargetFailurePhase::Receiving,
|
||||||
|
kind: ManagedTargetFailureKind::Timeout,
|
||||||
|
message: Arc::from("session silent while sending probes"),
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let TargetPhase::Active {
|
||||||
|
client, schedule, ..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
schedule.permit_probe_at(now);
|
||||||
|
if schedule.is_finished() && !client.has_pending_probes() {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
return self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||||
|
}
|
||||||
|
received
|
||||||
|
}
|
||||||
|
TargetPhase::Draining { client, .. } => {
|
||||||
|
if client
|
||||||
|
.next_probe_timeout_deadline()
|
||||||
|
.is_some_and(|timeout| timeout <= now)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut retained_state_changed = false;
|
||||||
|
let received = match client.poll_recv(cx) {
|
||||||
|
Poll::Pending => false,
|
||||||
|
Poll::Ready(Ok(events)) => {
|
||||||
|
self.publish_client_events(index, events);
|
||||||
|
retained_state_changed = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
cleanup_failure.get_or_insert_with(|| {
|
||||||
|
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
|
||||||
|
});
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.targets[index].phase().client().is_peer_closed() {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
cleanup_failure, ..
|
||||||
|
} = self.targets[index].phase()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
self.finish_target(
|
||||||
|
index,
|
||||||
|
ManagedTargetEndReason::PeerClosed,
|
||||||
|
cleanup_failure.clone(),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if retained_state_changed {
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
client,
|
||||||
|
drain_started_at,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
match candidate {
|
||||||
|
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||||
|
None => {
|
||||||
|
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
post_deadline_receives_remaining,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
if now >= *deadline {
|
||||||
|
if received && *post_deadline_receives_remaining > 1 {
|
||||||
|
*post_deadline_receives_remaining -= 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
self.begin_close(index, primary_end, cleanup_failure, now)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
received
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Closing { client, .. } => {
|
||||||
|
let result = client.poll_close(cx);
|
||||||
|
let TargetPhase::Closing {
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let deadline = *deadline;
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
match result {
|
||||||
|
Poll::Pending => {
|
||||||
|
if now >= deadline {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
self.finish_target(
|
||||||
|
index,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure.or_else(|| Some(close_timeout_failure())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(events)) => {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
self.publish_client_events(index, events);
|
||||||
|
self.finish_target(index, primary_end, cleanup_failure);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Poll::Ready(Err(error)) => {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
let cleanup =
|
||||||
|
classify_client_error(ManagedTargetFailurePhase::Closing, &error);
|
||||||
|
self.finish_target(index, primary_end, cleanup_failure.or(Some(cleanup)));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TargetPhase::Terminal { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_drain(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
primary_end: ManagedTargetEndReason,
|
||||||
|
_now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
let drain_started_at = Instant::now();
|
||||||
|
let Some(deadline) =
|
||||||
|
self.drain_deadline(self.targets[index].phase().client(), drain_started_at)
|
||||||
|
else {
|
||||||
|
return self.begin_close(
|
||||||
|
index,
|
||||||
|
primary_end,
|
||||||
|
Some(duration_overflow_failure()),
|
||||||
|
drain_started_at,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let previous = self.targets[index].take_phase();
|
||||||
|
let lifecycle = previous.lifecycle();
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Draining {
|
||||||
|
client: previous.into_client(),
|
||||||
|
drain_started_at,
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure: None,
|
||||||
|
post_deadline_receives_remaining: POST_DEADLINE_RECEIVE_BUDGET,
|
||||||
|
});
|
||||||
|
self.publish_target_phase(index, lifecycle);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn drain_deadline(
|
||||||
|
&self,
|
||||||
|
client: &AsyncClient,
|
||||||
|
drain_started_at: Instant,
|
||||||
|
) -> Option<Instant> {
|
||||||
|
client
|
||||||
|
.latest_probe_timeout_deadline()
|
||||||
|
.unwrap_or(drain_started_at)
|
||||||
|
.max(drain_started_at)
|
||||||
|
.checked_add(self.config.final_drain)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_close(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
primary_end: ManagedTargetEndReason,
|
||||||
|
mut cleanup_failure: Option<ManagedTargetFailure>,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
let deadline = now.checked_add(self.config.final_drain).unwrap_or_else(|| {
|
||||||
|
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||||
|
now
|
||||||
|
});
|
||||||
|
let previous = self.targets[index].take_phase();
|
||||||
|
let lifecycle = previous.lifecycle();
|
||||||
|
self.targets[index].restore_phase(TargetPhase::Closing {
|
||||||
|
client: previous.into_client(),
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
});
|
||||||
|
self.publish_target_phase(index, lifecycle);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn duration_overflow_failure() -> ManagedTargetFailure {
|
||||||
|
classify_client_error(
|
||||||
|
ManagedTargetFailurePhase::Timing,
|
||||||
|
&ClientError::DurationOverflow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_timeout_failure() -> ManagedTargetFailure {
|
||||||
|
ManagedTargetFailure {
|
||||||
|
phase: ManagedTargetFailurePhase::Closing,
|
||||||
|
kind: ManagedTargetFailureKind::Timeout,
|
||||||
|
message: Arc::from("best-effort close did not become writable before its deadline"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! Bounded timeout discovery and processing across target generations.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::managed::{classify_client_error, ManagedTargetFailurePhase};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
target::{duration_overflow_failure, OpenSessionFailureCleanup, TargetPhase},
|
||||||
|
ManagedClientTask,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) const TIMEOUT_WORK_BUDGET: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(super) struct TimeoutBacklogEntry {
|
||||||
|
index: usize,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct TimeoutStep {
|
||||||
|
more_due: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeoutStep {
|
||||||
|
const NONE: Self = Self { more_due: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedClientTask {
|
||||||
|
pub(super) fn poll_timeout_pass(&mut self, now: Instant) -> bool {
|
||||||
|
let target_len = self.targets.len();
|
||||||
|
if target_len == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut transitions_remaining = TIMEOUT_WORK_BUDGET;
|
||||||
|
for _ in 0..target_len.min(TIMEOUT_WORK_BUDGET) {
|
||||||
|
if transitions_remaining == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let index = self.timeout_cursor;
|
||||||
|
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
|
||||||
|
if self.target_has_due_timeout(index, now) {
|
||||||
|
let generation = self.targets[index].instance.generation;
|
||||||
|
let step = self.poll_target_timeout(index, now);
|
||||||
|
transitions_remaining -= 1;
|
||||||
|
if step.more_due {
|
||||||
|
self.enqueue_timeout_backlog(index, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while transitions_remaining > 0 {
|
||||||
|
let Some(entry) = self.timeout_backlog.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let valid = self
|
||||||
|
.targets
|
||||||
|
.get(entry.index)
|
||||||
|
.is_some_and(|target| target.instance.generation == entry.generation)
|
||||||
|
&& self.target_has_due_timeout(entry.index, now);
|
||||||
|
if !valid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = self.poll_target_timeout(entry.index, now);
|
||||||
|
transitions_remaining -= 1;
|
||||||
|
if step.more_due {
|
||||||
|
self.enqueue_timeout_backlog(entry.index, entry.generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!self.timeout_backlog.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_timeout_backlog(&mut self, index: usize, generation: u64) {
|
||||||
|
if self.timeout_backlog.len() == TIMEOUT_WORK_BUDGET
|
||||||
|
|| self
|
||||||
|
.timeout_backlog
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.index == index && entry.generation == generation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.timeout_backlog
|
||||||
|
.push_back(TimeoutBacklogEntry { index, generation });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
self.timeout_inspections.borrow_mut().push(index);
|
||||||
|
match self.targets[index].phase() {
|
||||||
|
TargetPhase::Active { client, .. } => {
|
||||||
|
!self.phase.is_stopping()
|
||||||
|
&& self.effective_retirement(index).is_none()
|
||||||
|
&& client
|
||||||
|
.next_probe_timeout_deadline()
|
||||||
|
.is_some_and(|deadline| deadline <= now)
|
||||||
|
}
|
||||||
|
TargetPhase::Draining { client, .. } => client
|
||||||
|
.next_probe_timeout_deadline()
|
||||||
|
.is_some_and(|timeout| timeout <= now),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_target_timeout(&mut self, index: usize, now: Instant) -> TimeoutStep {
|
||||||
|
match self.targets[index].phase_mut() {
|
||||||
|
TargetPhase::Active { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||||
|
Ok(batch) => {
|
||||||
|
self.publish_client_events(index, batch.events);
|
||||||
|
TimeoutStep {
|
||||||
|
more_due: batch.more_due,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.begin_open_session_failure(
|
||||||
|
index,
|
||||||
|
ManagedTargetFailurePhase::Timing,
|
||||||
|
error,
|
||||||
|
now,
|
||||||
|
OpenSessionFailureCleanup::Close,
|
||||||
|
);
|
||||||
|
TimeoutStep::NONE
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TargetPhase::Draining { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||||
|
Ok(batch) => {
|
||||||
|
self.publish_client_events(index, batch.events);
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
client,
|
||||||
|
drain_started_at,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
deadline,
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
match candidate {
|
||||||
|
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||||
|
None => {
|
||||||
|
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||||
|
return TimeoutStep::NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimeoutStep {
|
||||||
|
more_due: batch.more_due,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.targets[index].sync_live_packets_sent();
|
||||||
|
let TargetPhase::Draining {
|
||||||
|
primary_end,
|
||||||
|
cleanup_failure,
|
||||||
|
..
|
||||||
|
} = self.targets[index].phase_mut()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
cleanup_failure.get_or_insert_with(|| {
|
||||||
|
classify_client_error(ManagedTargetFailurePhase::Timing, &error)
|
||||||
|
});
|
||||||
|
let primary_end = primary_end.clone();
|
||||||
|
let cleanup_failure = cleanup_failure.clone();
|
||||||
|
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||||
|
TimeoutStep::NONE
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => TimeoutStep::NONE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::managed::{ManagedClientConfig, ManagedTargetConfig};
|
||||||
|
|
||||||
|
use super::{super::ManagedClient, Instant, TIMEOUT_WORK_BUDGET};
|
||||||
|
|
||||||
|
// Poll latency through public events cannot distinguish an O(n) discovery
|
||||||
|
// scan from a bounded scan reliably. This tiny inspection trace measures
|
||||||
|
// work directly, including empty targets, and checks eventual coverage
|
||||||
|
// without asserting a cursor value or a particular visitation order.
|
||||||
|
#[test]
|
||||||
|
fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() {
|
||||||
|
let count = TIMEOUT_WORK_BUDGET * 2 + 1;
|
||||||
|
let targets = (0..count)
|
||||||
|
.map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112"))
|
||||||
|
.collect();
|
||||||
|
let (mut task, _handle) = ManagedClient::task(
|
||||||
|
ManagedClientConfig {
|
||||||
|
max_live_target_generations: count,
|
||||||
|
..ManagedClientConfig::default()
|
||||||
|
},
|
||||||
|
targets,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) {
|
||||||
|
task.timeout_inspections.borrow_mut().clear();
|
||||||
|
task.poll_timeout_pass(Instant::now());
|
||||||
|
let inspected = task.timeout_inspections.borrow();
|
||||||
|
assert!(inspected.len() <= TIMEOUT_WORK_BUDGET);
|
||||||
|
visited.extend(inspected.iter().copied());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
visited.len(),
|
||||||
|
count,
|
||||||
|
"timeout discovery must reach all targets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! Atomic desired-target transactions, generation retirement, and pruning.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
managed::{
|
||||||
|
ManagedCommandAcknowledgement, ManagedCommandApplyError, ManagedCompletionPolicy,
|
||||||
|
ManagedEvent, ManagedTargetConfig, ManagedTargetLifecycle, TargetInstance,
|
||||||
|
},
|
||||||
|
ClientConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
control::{validate_target_config, ManagedCommand, UpdateAdmission},
|
||||||
|
target::{RetirementReason, TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||||
|
ManagedClientTask, TaskPhase,
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMAND_WORK_BUDGET: usize = 32;
|
||||||
|
|
||||||
|
struct PlannedTarget {
|
||||||
|
target: ManagedTargetConfig,
|
||||||
|
client_config: ClientConfig,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlannedRetirement {
|
||||||
|
index: usize,
|
||||||
|
reason: RetirementReason,
|
||||||
|
synchronous: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdatePlan {
|
||||||
|
retirements: Vec<PlannedRetirement>,
|
||||||
|
created: Vec<PlannedTarget>,
|
||||||
|
next_generation: u64,
|
||||||
|
next_command_sequence: u64,
|
||||||
|
prospective_live_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synchronously_retireable(phase: &TargetPhase) -> bool {
|
||||||
|
match phase {
|
||||||
|
TargetPhase::Retrying { .. }
|
||||||
|
| TargetPhase::Pending { .. }
|
||||||
|
| TargetPhase::Connecting { .. }
|
||||||
|
| TargetPhase::Terminal { .. } => true,
|
||||||
|
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
|
||||||
|
TargetPhase::Active { .. } | TargetPhase::Draining { .. } | TargetPhase::Closing { .. } => {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_satisfies_target(runtime: &TargetRuntime, target: &ManagedTargetConfig) -> bool {
|
||||||
|
runtime.membership.is_desired()
|
||||||
|
&& !matches!(runtime.phase(), TargetPhase::Terminal { .. })
|
||||||
|
&& runtime.config == *target
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedClientTask {
|
||||||
|
pub(super) fn process_commands(&mut self, cx: &mut Context<'_>) -> bool {
|
||||||
|
let mut immediate = false;
|
||||||
|
for _ in 0..COMMAND_WORK_BUDGET {
|
||||||
|
match self.commands.poll_recv(cx) {
|
||||||
|
Poll::Ready(Some(ManagedCommand::UpdateTargets {
|
||||||
|
targets,
|
||||||
|
acknowledgement,
|
||||||
|
})) => {
|
||||||
|
// This check is the driver-side application linearization point.
|
||||||
|
// `apply_targets` cannot suspend, so a stop observed here wins over
|
||||||
|
// this command; once it passes, this command may complete atomically.
|
||||||
|
let result = if !self.phase.is_running()
|
||||||
|
|| self.resources().stop.update_admission() != UpdateAdmission::Open
|
||||||
|
{
|
||||||
|
Err(ManagedCommandApplyError::Stopping)
|
||||||
|
} else {
|
||||||
|
self.apply_targets(targets)
|
||||||
|
};
|
||||||
|
let _ = acknowledgement.send(result);
|
||||||
|
immediate = true;
|
||||||
|
}
|
||||||
|
Poll::Ready(None) | Poll::Pending => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_targets(
|
||||||
|
&mut self,
|
||||||
|
incoming: Vec<ManagedTargetConfig>,
|
||||||
|
) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||||
|
let plan = self.plan_targets(incoming)?;
|
||||||
|
debug_assert!(plan.prospective_live_count <= self.config.max_live_target_generations);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut synchronous = HashSet::with_capacity(plan.retirements.len());
|
||||||
|
let mut finished = Vec::new();
|
||||||
|
for retirement in &plan.retirements {
|
||||||
|
let target = &mut self.targets[retirement.index];
|
||||||
|
let was_paced_active = target.is_paced_active();
|
||||||
|
target.membership = TargetMembership::Withdrawn(retirement.reason);
|
||||||
|
if retirement.synchronous {
|
||||||
|
synchronous.insert(retirement.index);
|
||||||
|
if !matches!(target.phase(), TargetPhase::Terminal { .. }) {
|
||||||
|
let outcome = Arc::new(target.outcome(retirement.reason.into(), None));
|
||||||
|
self.history.record(outcome.as_ref().clone());
|
||||||
|
finished.push(ManagedEvent::TargetFinished { outcome });
|
||||||
|
}
|
||||||
|
} else if was_paced_active {
|
||||||
|
// Removal is directional: it may discard an elapsed gate but never
|
||||||
|
// lengthens an existing future stagger gate.
|
||||||
|
self.stagger_target_removed(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !synchronous.is_empty() {
|
||||||
|
let mut index = 0;
|
||||||
|
self.targets.retain(|_| {
|
||||||
|
let keep = !synchronous.contains(&index);
|
||||||
|
index += 1;
|
||||||
|
keep
|
||||||
|
});
|
||||||
|
self.rebase_target_cursors();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut created_instances = Vec::with_capacity(plan.created.len());
|
||||||
|
for planned in plan.created {
|
||||||
|
let instance = TargetInstance {
|
||||||
|
id: planned.target.id.clone(),
|
||||||
|
generation: planned.generation,
|
||||||
|
};
|
||||||
|
created_instances.push(instance.clone());
|
||||||
|
self.targets.push(TargetRuntime {
|
||||||
|
instance,
|
||||||
|
server_addr: Arc::from(planned.target.server_addr.clone()),
|
||||||
|
config: planned.target,
|
||||||
|
membership: TargetMembership::Desired,
|
||||||
|
remote: None,
|
||||||
|
counters: TargetCounters::default(),
|
||||||
|
sent_before_session: 0,
|
||||||
|
retry_delay: self.config.reconnect_initial_delay,
|
||||||
|
silence_since: None,
|
||||||
|
last_reply_at: None,
|
||||||
|
phase: Some(TargetPhase::Pending {
|
||||||
|
client_config: planned.client_config,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.next_generation = plan.next_generation;
|
||||||
|
self.applied_command_sequence = plan.next_command_sequence;
|
||||||
|
let stopping = self
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.all(|runtime| !runtime.membership.is_desired())
|
||||||
|
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
|
||||||
|
if stopping {
|
||||||
|
self.resources().stop.begin_stopping();
|
||||||
|
self.phase = TaskPhase::Stopping;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transaction has one externally visible durable point: all runtime and
|
||||||
|
// history changes above, then this exact status snapshot, then its events and
|
||||||
|
// acknowledgement. Do not use per-transition publishing helpers here.
|
||||||
|
let status = self.snapshot();
|
||||||
|
self.resources().status.send_replace(Arc::clone(&status));
|
||||||
|
if stopping {
|
||||||
|
self.publish_event(ManagedEvent::Stopping);
|
||||||
|
}
|
||||||
|
for event in finished {
|
||||||
|
self.publish_event(event);
|
||||||
|
}
|
||||||
|
for target in created_instances {
|
||||||
|
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||||
|
target,
|
||||||
|
lifecycle: ManagedTargetLifecycle::Pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ManagedCommandAcknowledgement {
|
||||||
|
sequence: self.applied_command_sequence,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_targets(
|
||||||
|
&self,
|
||||||
|
incoming: Vec<ManagedTargetConfig>,
|
||||||
|
) -> Result<UpdatePlan, ManagedCommandApplyError> {
|
||||||
|
let mut ids = HashSet::with_capacity(incoming.len());
|
||||||
|
let mut prepared = Vec::with_capacity(incoming.len());
|
||||||
|
for target in incoming {
|
||||||
|
if !ids.insert(target.id.clone()) {
|
||||||
|
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
||||||
|
}
|
||||||
|
let mut client_config = self.config.client.clone();
|
||||||
|
client_config.address_family = target
|
||||||
|
.address_family
|
||||||
|
.unwrap_or(self.config.client.address_family);
|
||||||
|
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||||
|
validate_target_config(&client_config).map_err(|source| {
|
||||||
|
ManagedCommandApplyError::InvalidTarget {
|
||||||
|
id: target.id.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
prepared.push((target, client_config));
|
||||||
|
}
|
||||||
|
let mut retirements = Vec::new();
|
||||||
|
for (index, runtime) in self.targets.iter().enumerate() {
|
||||||
|
if !runtime.membership.is_desired()
|
||||||
|
|| prepared
|
||||||
|
.iter()
|
||||||
|
.any(|(target, _)| runtime_satisfies_target(runtime, target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reason = if prepared
|
||||||
|
.iter()
|
||||||
|
.any(|(target, _)| target.id == runtime.instance.id)
|
||||||
|
{
|
||||||
|
RetirementReason::Replaced
|
||||||
|
} else {
|
||||||
|
RetirementReason::Removed
|
||||||
|
};
|
||||||
|
retirements.push(PlannedRetirement {
|
||||||
|
index,
|
||||||
|
reason,
|
||||||
|
synchronous: synchronously_retireable(runtime.phase()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut next_generation = self.next_generation;
|
||||||
|
let mut created = Vec::new();
|
||||||
|
for (target, client_config) in prepared {
|
||||||
|
if self
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.any(|runtime| runtime_satisfies_target(runtime, &target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let generation = next_generation;
|
||||||
|
next_generation = next_generation
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ManagedCommandApplyError::GenerationExhausted)?;
|
||||||
|
created.push(PlannedTarget {
|
||||||
|
target,
|
||||||
|
client_config,
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let next_command_sequence = self
|
||||||
|
.applied_command_sequence
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ManagedCommandApplyError::CommandSequenceExhausted)?;
|
||||||
|
let synchronous = retirements
|
||||||
|
.iter()
|
||||||
|
.filter(|retirement| retirement.synchronous)
|
||||||
|
.map(|retirement| retirement.index)
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let prospective_live_count = self
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(index, _)| !synchronous.contains(index))
|
||||||
|
.count()
|
||||||
|
.saturating_add(created.len());
|
||||||
|
if prospective_live_count > self.config.max_live_target_generations {
|
||||||
|
return Err(ManagedCommandApplyError::LiveGenerationLimitExceeded {
|
||||||
|
required: prospective_live_count,
|
||||||
|
limit: self.config.max_live_target_generations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(UpdatePlan {
|
||||||
|
retirements,
|
||||||
|
created,
|
||||||
|
next_generation,
|
||||||
|
next_command_sequence,
|
||||||
|
prospective_live_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prune_undesired_terminal(&mut self) {
|
||||||
|
if !self.targets.iter().any(|target| {
|
||||||
|
!target.membership.is_desired()
|
||||||
|
&& matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.targets.retain(|target| {
|
||||||
|
target.membership.is_desired()
|
||||||
|
|| !matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||||
|
});
|
||||||
|
self.rebase_target_cursors();
|
||||||
|
self.replace_status();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebase_target_cursors(&mut self) {
|
||||||
|
let len = self.targets.len();
|
||||||
|
if len == 0 {
|
||||||
|
self.cursor = 0;
|
||||||
|
self.timeout_cursor = 0;
|
||||||
|
self.send_cursor = 0;
|
||||||
|
} else {
|
||||||
|
self.cursor %= len;
|
||||||
|
self.timeout_cursor %= len;
|
||||||
|
self.send_cursor %= len;
|
||||||
|
}
|
||||||
|
self.scan_remaining = self.scan_remaining.min(len);
|
||||||
|
self.burst_remaining = self.burst_remaining.min(len);
|
||||||
|
self.stagger_remaining = self.stagger_remaining.min(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::{error::Error, fmt, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
|
use crate::{ClientError, ClientEvent, ClientTimestamp};
|
||||||
|
|
||||||
|
/// An Echo datagram accepted by the UDP socket and committed in session state.
|
||||||
|
///
|
||||||
|
/// The wire sequence and sent count have advanced, and the probe is pending.
|
||||||
|
/// This receipt remains authoritative even if subsequent validation or kernel
|
||||||
|
/// TX timestamp processing fails. It carries transport facts only; managed
|
||||||
|
/// scheduling annotations belong on the corresponding [`ClientEvent::EchoSent`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SendReceipt {
|
||||||
|
/// Committed wire sequence number.
|
||||||
|
pub seq: u32,
|
||||||
|
/// Resolved remote socket address.
|
||||||
|
pub remote: SocketAddr,
|
||||||
|
/// Public measurement timestamp sampled immediately after socket acceptance.
|
||||||
|
/// The private pre-send anchor still determines the timeout deadline.
|
||||||
|
pub sent_at: ClientTimestamp,
|
||||||
|
/// Number of bytes the socket reported accepting.
|
||||||
|
pub bytes: usize,
|
||||||
|
/// Time spent in the successful socket send call.
|
||||||
|
pub send_call: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SendReceipt> for ClientEvent {
|
||||||
|
/// Construct the committed send event without scheduling annotations.
|
||||||
|
fn from(receipt: SendReceipt) -> Self {
|
||||||
|
Self::EchoSent {
|
||||||
|
seq: receipt.seq,
|
||||||
|
remote: receipt.remote,
|
||||||
|
scheduled_at: None,
|
||||||
|
sent_at: receipt.sent_at,
|
||||||
|
bytes: receipt.bytes,
|
||||||
|
send_call: receipt.send_call,
|
||||||
|
timer_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe-send failure with an explicit socket-acceptance commitment boundary.
|
||||||
|
///
|
||||||
|
/// There is deliberately no blanket conversion from [`ClientError`]: each send
|
||||||
|
/// failure must be classified at the point where its commitment is known.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SendProbeError {
|
||||||
|
/// No datagram was accepted and no probe state or sequence was committed.
|
||||||
|
NotCommitted(ClientError),
|
||||||
|
/// The probe was committed, then post-send processing failed.
|
||||||
|
/// The pending probe and advanced sequence/count remain committed.
|
||||||
|
AfterCommit {
|
||||||
|
/// Authoritative receipt for the accepted send.
|
||||||
|
receipt: SendReceipt,
|
||||||
|
/// Operational cause of the post-send failure.
|
||||||
|
source: Box<ClientError>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SendProbeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotCommitted(source) => write!(f, "probe send was not committed: {source}"),
|
||||||
|
Self::AfterCommit { source, .. } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"probe send was committed, but post-send processing failed: {source}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for SendProbeError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
Some(match self {
|
||||||
|
Self::NotCommitted(source) => source,
|
||||||
|
Self::AfterCommit { source, .. } => source.as_ref(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,9 @@ fn send_probe_fails_after_close() {
|
|||||||
client.close().unwrap();
|
client.close().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::AlreadyClosed)
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyClosed
|
||||||
|
))
|
||||||
));
|
));
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
@@ -206,9 +208,7 @@ fn close_flagged_retained_timeout_emits_late_then_closes() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_open_started(client.open().unwrap());
|
assert_open_started(client.open().unwrap());
|
||||||
let sent = client.send_probe().unwrap();
|
let sent = client.send_probe().unwrap();
|
||||||
let ClientEvent::EchoSent { sent_at, .. } = &sent[0] else {
|
let sent_at = sent.sent_at;
|
||||||
panic!("expected EchoSent");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client
|
client
|
||||||
.poll_timeouts_at(sent_at.mono + client.probe_timeout())
|
.poll_timeouts_at(sent_at.mono + client.probe_timeout())
|
||||||
@@ -426,10 +426,7 @@ fn wrong_token_close_flag_does_not_close_session() {
|
|||||||
}]
|
}]
|
||||||
));
|
));
|
||||||
assert!(!client.is_peer_closed());
|
assert!(!client.is_peer_closed());
|
||||||
assert!(matches!(
|
assert_eq!(client.send_probe().unwrap().seq, 1);
|
||||||
client.send_probe().unwrap().as_slice(),
|
|
||||||
[ClientEvent::EchoSent { seq: 1, .. }]
|
|
||||||
));
|
|
||||||
client.close().unwrap();
|
client.close().unwrap();
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
@@ -498,7 +495,9 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::AlreadyClosed)
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyClosed
|
||||||
|
))
|
||||||
));
|
));
|
||||||
|
|
||||||
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ fn send_probe_fails_after_no_test_completed() {
|
|||||||
assert_no_test_completed(client.open().unwrap());
|
assert_no_test_completed(client.open().unwrap());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::AlreadyCompleted)
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyCompleted
|
||||||
|
))
|
||||||
));
|
));
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ fn probe_timeout_overflow_transmits_no_echo() {
|
|||||||
assert_open_started(client.open().unwrap());
|
assert_open_started(client.open().unwrap());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::DurationOverflow)
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::DurationOverflow
|
||||||
|
))
|
||||||
));
|
));
|
||||||
assert!(!client.has_pending_probes());
|
assert!(!client.has_pending_probes());
|
||||||
client.close().unwrap();
|
client.close().unwrap();
|
||||||
@@ -181,7 +183,9 @@ fn pending_full_does_not_send_packet() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::PendingLimitExceeded { limit: 2 }
|
||||||
|
))
|
||||||
));
|
));
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(30));
|
thread::sleep(Duration::from_millis(30));
|
||||||
|
|||||||
@@ -261,10 +261,23 @@ fn a_closed_udp_peer_surfaces_a_socket_error() {
|
|||||||
close_tx.send(()).unwrap();
|
close_tx.send(()).unwrap();
|
||||||
task.join().unwrap();
|
task.join().unwrap();
|
||||||
// Refusal can arrive during send's TX drain or the following receive.
|
// Refusal can arrive during send's TX drain or the following receive.
|
||||||
let result = client.send_probe().and_then(|_| client.recv_once());
|
// Either way this first Echo was accepted and must stay pending.
|
||||||
|
let (receipt, error) = match client.send_probe() {
|
||||||
|
Ok(receipt) => (receipt, client.recv_once().unwrap_err()),
|
||||||
|
Err(irtt_client::SendProbeError::AfterCommit { receipt, source }) => (receipt, *source),
|
||||||
|
Err(error) => panic!("first Echo should be accepted: {error:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(receipt.seq, 0);
|
||||||
|
assert_eq!(receipt.remote, addr);
|
||||||
|
assert!(client.has_pending_probes());
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, Err(irtt_client::ClientError::Socket(ref error))
|
matches!(error, irtt_client::ClientError::Socket(ref error)
|
||||||
if error.kind() == std::io::ErrorKind::ConnectionRefused),
|
if error.kind() == std::io::ErrorKind::ConnectionRefused),
|
||||||
"{result:?}"
|
"{error:?}"
|
||||||
);
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
client.poll_timeouts_at(receipt.sent_at.mono + client.probe_timeout())
|
||||||
|
.unwrap().as_slice(),
|
||||||
|
[ClientEvent::EchoLoss { seq: 0, sent_at, .. }] if *sent_at == receipt.sent_at
|
||||||
|
));
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,32 @@ fn config() -> ManagedClientConfig {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
||||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||||
|
let mut subscription: ManagedStatusSubscription = handle.subscribe_status();
|
||||||
|
assert_eq!(
|
||||||
|
subscription.borrow().lifecycle,
|
||||||
|
ManagedLifecycle::NotStarted
|
||||||
|
);
|
||||||
|
assert!(!subscription.borrow().stop_requested);
|
||||||
|
assert!(subscription.borrow().final_outcome.is_none());
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let mut task = pin!(task);
|
||||||
|
{
|
||||||
|
let _runtime = runtime.enter();
|
||||||
|
assert!(task
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
|
}
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||||
|
subscription = handle.subscribe_status();
|
||||||
|
assert_eq!(subscription.borrow().lifecycle, ManagedLifecycle::Running);
|
||||||
|
assert!(pin!(subscription.changed())
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
let receipts: Vec<_> = (0..3)
|
let receipts: Vec<_> = (0..3)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
handle
|
handle
|
||||||
@@ -35,15 +61,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
|
|||||||
Err(ManagedCommandError::QueueFull)
|
Err(ManagedCommandError::QueueFull)
|
||||||
));
|
));
|
||||||
|
|
||||||
let stop = handle.stop();
|
let mut stop = pin!(handle.stop());
|
||||||
|
assert!(stop
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::Stopping)
|
Err(ManagedCommandError::Stopping)
|
||||||
));
|
));
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
runtime.block_on(async {
|
runtime.block_on(async {
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
let outcome = task.await;
|
let outcome = task.await;
|
||||||
@@ -61,6 +88,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
|
|||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
assert!(status.stop_requested);
|
assert!(status.stop_requested);
|
||||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||||
|
subscription.changed().await.unwrap();
|
||||||
|
assert!(std::sync::Arc::ptr_eq(
|
||||||
|
&subscription.borrow_and_update(),
|
||||||
|
&status
|
||||||
|
));
|
||||||
|
assert!(subscription.changed().await.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
subscription.borrow().final_outcome.as_deref(),
|
||||||
|
Some(&outcome)
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::DriverClosed)
|
Err(ManagedCommandError::DriverClosed)
|
||||||
@@ -103,12 +140,14 @@ fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() {
|
|||||||
}
|
}
|
||||||
let status = handle.status();
|
let status = handle.status();
|
||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
||||||
|
assert!(!status.stop_requested);
|
||||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::DriverClosed)
|
Err(ManagedCommandError::DriverClosed)
|
||||||
));
|
));
|
||||||
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ publish = false
|
|||||||
irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
|
irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
|
||||||
irtt-server.workspace = true
|
irtt-server.workspace = true
|
||||||
irtt-proto.workspace = true
|
irtt-proto.workspace = true
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||||
@@ -72,14 +72,10 @@ async fn exercise_client_server() {
|
|||||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(matches!(
|
let receipt = client.send_probe().await.unwrap();
|
||||||
client.send_probe().await.unwrap().as_slice(),
|
assert_eq!(receipt.seq, 0);
|
||||||
[ClientEvent::EchoSent {
|
assert_eq!(receipt.remote, server_addr);
|
||||||
seq: 0,
|
assert!(client.has_pending_probes());
|
||||||
remote,
|
|
||||||
..
|
|
||||||
}] if *remote == server_addr
|
|
||||||
));
|
|
||||||
let events = client.recv().await.unwrap();
|
let events = client.recv().await.unwrap();
|
||||||
let [ClientEvent::EchoReply {
|
let [ClientEvent::EchoReply {
|
||||||
seq: 0,
|
seq: 0,
|
||||||
@@ -97,6 +93,7 @@ async fn exercise_client_server() {
|
|||||||
panic!("expected a measured reply, got {events:?}");
|
panic!("expected a measured reply, got {events:?}");
|
||||||
};
|
};
|
||||||
assert_eq!(*remote, server_addr);
|
assert_eq!(*remote, server_addr);
|
||||||
|
assert_eq!(*sent_at, receipt.sent_at);
|
||||||
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
||||||
// Verify the measurement endpoint through public reply metadata and real
|
// Verify the measurement endpoint through public reply metadata and real
|
||||||
// OS timestamps. This also covers the userspace fallback on other targets.
|
// OS timestamps. This also covers the userspace fallback on other targets.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use tokio::runtime::{Builder, Runtime};
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use irtt_client::{AsyncClient, ClientConfig, ClientError, ClientEvent, OpenOutcome};
|
use irtt_client::{AsyncClient, ClientConfig, ClientError, ClientEvent, OpenOutcome};
|
||||||
use irtt_client::{Client, RunMode};
|
use irtt_client::{Client, RunMode, SendProbeError};
|
||||||
use irtt_server::ServerConfig;
|
use irtt_server::ServerConfig;
|
||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
@@ -274,6 +274,11 @@ fn recv_before_open_fails_on_first_poll_without_socket_readiness() {
|
|||||||
poll_recv_once(&mut client),
|
poll_recv_once(&mut client),
|
||||||
Poll::Ready(Err(ClientError::NotOpen))
|
Poll::Ready(Err(ClientError::NotOpen))
|
||||||
));
|
));
|
||||||
|
let mut send = Box::pin(client.send_probe());
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(send.as_mut()),
|
||||||
|
Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::NotOpen)))
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +381,13 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
|
|||||||
poll_recv_once(&mut client),
|
poll_recv_once(&mut client),
|
||||||
Poll::Ready(Err(ClientError::AlreadyClosed))
|
Poll::Ready(Err(ClientError::AlreadyClosed))
|
||||||
));
|
));
|
||||||
|
let mut send = Box::pin(client.send_probe());
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(send.as_mut()),
|
||||||
|
Poll::Ready(Err(SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyClosed
|
||||||
|
)))
|
||||||
|
));
|
||||||
});
|
});
|
||||||
assert_eq!(close_server.finish().len(), 2);
|
assert_eq!(close_server.finish().len(), 2);
|
||||||
|
|
||||||
@@ -391,6 +403,13 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
|
|||||||
poll_recv_once(&mut client),
|
poll_recv_once(&mut client),
|
||||||
Poll::Ready(Err(ClientError::AlreadyCompleted))
|
Poll::Ready(Err(ClientError::AlreadyCompleted))
|
||||||
));
|
));
|
||||||
|
let mut send = Box::pin(client.send_probe());
|
||||||
|
assert!(matches!(
|
||||||
|
poll_once(send.as_mut()),
|
||||||
|
Poll::Ready(Err(SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyCompleted
|
||||||
|
)))
|
||||||
|
));
|
||||||
});
|
});
|
||||||
assert_eq!(no_test_server.finish().len(), 1);
|
assert_eq!(no_test_server.finish().len(), 1);
|
||||||
}
|
}
|
||||||
@@ -556,7 +575,7 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
|
|||||||
assert!(!blocking.has_pending_probes());
|
assert!(!blocking.has_pending_probes());
|
||||||
let blocking_second = blocking.open().unwrap();
|
let blocking_second = blocking.open().unwrap();
|
||||||
assert!(!blocking.has_pending_probes());
|
assert!(!blocking.has_pending_probes());
|
||||||
let blocking_sent = blocking.send_probe().unwrap();
|
let blocking_sent = [ClientEvent::from(blocking.send_probe().unwrap())];
|
||||||
let blocking_reply = blocking.recv_once().unwrap();
|
let blocking_reply = blocking.recv_once().unwrap();
|
||||||
let blocking_close = blocking.close().unwrap();
|
let blocking_close = blocking.close().unwrap();
|
||||||
assert_eq!(blocking_server.finish().len(), 4);
|
assert_eq!(blocking_server.finish().len(), 4);
|
||||||
@@ -572,7 +591,7 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
|
|||||||
assert!(!client.has_pending_probes());
|
assert!(!client.has_pending_probes());
|
||||||
let second = client.open().await.unwrap();
|
let second = client.open().await.unwrap();
|
||||||
assert!(!client.has_pending_probes());
|
assert!(!client.has_pending_probes());
|
||||||
let sent = client.send_probe().await.unwrap();
|
let sent = [ClientEvent::from(client.send_probe().await.unwrap())];
|
||||||
let reply = client.recv().await.unwrap();
|
let reply = client.recv().await.unwrap();
|
||||||
let closed = client.close().await.unwrap();
|
let closed = client.close().await.unwrap();
|
||||||
(first, second, sent, reply, closed)
|
(first, second, sent, reply, closed)
|
||||||
@@ -624,7 +643,7 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
|
|||||||
let mut blocking_sent = Vec::new();
|
let mut blocking_sent = Vec::new();
|
||||||
let mut blocking_replies = Vec::new();
|
let mut blocking_replies = Vec::new();
|
||||||
for _ in 0..PROBES {
|
for _ in 0..PROBES {
|
||||||
blocking_sent.extend(blocking.send_probe().unwrap());
|
blocking_sent.push(ClientEvent::from(blocking.send_probe().unwrap()));
|
||||||
assert!(blocking.has_pending_probes());
|
assert!(blocking.has_pending_probes());
|
||||||
assert!(blocking.next_probe_timeout_deadline().is_some());
|
assert!(blocking.next_probe_timeout_deadline().is_some());
|
||||||
blocking_replies.extend(blocking.recv_once().unwrap());
|
blocking_replies.extend(blocking.recv_once().unwrap());
|
||||||
@@ -644,7 +663,7 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
|
|||||||
let mut sent = Vec::new();
|
let mut sent = Vec::new();
|
||||||
let mut replies = Vec::new();
|
let mut replies = Vec::new();
|
||||||
for _ in 0..PROBES {
|
for _ in 0..PROBES {
|
||||||
sent.extend(client.send_probe().await.unwrap());
|
sent.push(ClientEvent::from(client.send_probe().await.unwrap()));
|
||||||
assert!(client.has_pending_probes());
|
assert!(client.has_pending_probes());
|
||||||
assert!(client.next_probe_timeout_deadline().is_some());
|
assert!(client.next_probe_timeout_deadline().is_some());
|
||||||
replies.extend(client.recv().await.unwrap());
|
replies.extend(client.recv().await.unwrap());
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
|
|||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.send_probe(),
|
client.send_probe(),
|
||||||
Err(ClientError::AlreadyClosed)
|
Err(irtt_client::SendProbeError::NotCommitted(
|
||||||
|
ClientError::AlreadyClosed
|
||||||
|
))
|
||||||
));
|
));
|
||||||
|
|
||||||
let observations = server.observations(2);
|
let observations = server.observations(2);
|
||||||
@@ -175,7 +177,7 @@ fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
|
|||||||
assert_started(outcome, ¶ms);
|
assert_started(outcome, ¶ms);
|
||||||
|
|
||||||
let sent = client.send_probe().unwrap();
|
let sent = client.send_probe().unwrap();
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.seq, 0);
|
||||||
let events = client.recv_once().unwrap();
|
let events = client.recv_once().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
events.as_slice(),
|
events.as_slice(),
|
||||||
@@ -292,7 +294,7 @@ fn backend_hmac_correct_key_succeeds() {
|
|||||||
assert!(matches!(outcome, OpenOutcome::Started(_)));
|
assert!(matches!(outcome, OpenOutcome::Started(_)));
|
||||||
|
|
||||||
let sent = client.send_probe().unwrap();
|
let sent = client.send_probe().unwrap();
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.seq, 0);
|
||||||
|
|
||||||
let events = client.recv_once().unwrap();
|
let events = client.recv_once().unwrap();
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use std::{collections::HashMap, time::Duration};
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use irtt_client::{managed::*, ClientConfig, ClientEvent, OpenPolicy, SessionRequest};
|
use irtt_client::{managed::*, ClientConfig, ClientEvent, OpenPolicy, SessionRequest};
|
||||||
use irtt_server::ServerConfig;
|
use irtt_server::ServerConfig;
|
||||||
@@ -186,6 +186,9 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
|
|||||||
);
|
);
|
||||||
assert!(status.targets.iter().all(|target| target.target != first
|
assert!(status.targets.iter().all(|target| target.target != first
|
||||||
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
||||||
|
if let Some(target) = status.targets.iter().find(|target| target.target == first) {
|
||||||
|
assert!(Arc::ptr_eq(target.outcome.as_ref().unwrap(), &outcome));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,11 +227,21 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
|
|||||||
);
|
);
|
||||||
assert!(status.targets.iter().all(|target| target.target != second
|
assert!(status.targets.iter().all(|target| target.target != second
|
||||||
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
||||||
|
let target = status
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| target.target == second)
|
||||||
|
.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(target.outcome.as_ref().unwrap(), &outcome));
|
||||||
}
|
}
|
||||||
ManagedEvent::Completed { outcome } => {
|
ManagedEvent::Completed { outcome } => {
|
||||||
let status = handle.status();
|
let status = handle.status();
|
||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
status.final_outcome.as_ref().unwrap(),
|
||||||
|
&outcome
|
||||||
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -238,23 +238,50 @@ fn quiescent_rejects_empty_initial_targets() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_empty_waits_for_stop() {
|
fn explicit_empty_waits_for_stop() {
|
||||||
|
let server = start_server(ServerBehavior::Echo, None);
|
||||||
let mut config = config(ManagedPacing::Staggered);
|
let mut config = config(ManagedPacing::Staggered);
|
||||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
config.client.request.duration = None;
|
||||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||||
let outcome = runtime().block_on(async move {
|
let mut events = handle.subscribe().unwrap();
|
||||||
let mut task = Box::pin(task);
|
let outcome = runtime().block_on(async {
|
||||||
assert!(
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
tokio::time::timeout(Duration::from_millis(20), task.as_mut())
|
let mut task = Box::pin(task);
|
||||||
.await
|
assert!(poll_once(task.as_mut()).is_pending());
|
||||||
.is_err()
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||||
);
|
let update = handle
|
||||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
.update_targets(vec![target("one", server.addr)])
|
||||||
let receipt = handle.stop();
|
.unwrap();
|
||||||
let outcome = task.await;
|
loop {
|
||||||
receipt.await;
|
tokio::select! {
|
||||||
outcome
|
_ = task.as_mut() => panic!("explicit-stop task completed before stop"),
|
||||||
|
event = events.recv() => {
|
||||||
|
if matches!(event.unwrap(), ManagedEvent::Client {
|
||||||
|
event: irtt_client::ClientEvent::EchoReply { .. }, ..
|
||||||
|
}) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update.await.unwrap();
|
||||||
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
assert!(poll_once(task.as_mut()).is_pending());
|
||||||
|
let status = handle.status();
|
||||||
|
assert_eq!(status.lifecycle, ManagedLifecycle::Stopping);
|
||||||
|
assert!(status.stop_requested);
|
||||||
|
assert!(status.final_outcome.is_none());
|
||||||
|
// The driver is paused during graceful cleanup: receipt readiness must
|
||||||
|
// come from durable stop observation, not eventual task completion.
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
task.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stop observation and cleanup must complete")
|
||||||
});
|
});
|
||||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
assert!(has_close(&server.finish()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -311,6 +338,8 @@ fn pre_poll_stop() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut receipt = Box::pin(handle.stop());
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
let mut task = Box::pin(task);
|
let mut task = Box::pin(task);
|
||||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||||
panic!("pre-stopped task did not complete")
|
panic!("pre-stopped task did not complete")
|
||||||
@@ -454,6 +483,12 @@ fn event_loss_independence() {
|
|||||||
let mut config = config(ManagedPacing::Staggered);
|
let mut config = config(ManagedPacing::Staggered);
|
||||||
config.event_capacity = 1;
|
config.event_capacity = 1;
|
||||||
let (task, handle) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap();
|
let (task, handle) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap();
|
||||||
|
let initial = handle.status();
|
||||||
|
assert_eq!(
|
||||||
|
initial.targets[0].lifecycle,
|
||||||
|
ManagedTargetLifecycle::Pending
|
||||||
|
);
|
||||||
|
assert!(initial.targets[0].outcome.is_none());
|
||||||
let mut events = handle.subscribe().unwrap();
|
let mut events = handle.subscribe().unwrap();
|
||||||
let outcome = runtime().block_on(task);
|
let outcome = runtime().block_on(task);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -461,7 +496,35 @@ fn event_loss_independence() {
|
|||||||
Err(broadcast::error::TryRecvError::Lagged(_))
|
Err(broadcast::error::TryRecvError::Lagged(_))
|
||||||
));
|
));
|
||||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Completed);
|
let status = handle.status();
|
||||||
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
|
assert!(!status.stop_requested);
|
||||||
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
assert!(Arc::ptr_eq(&status, &handle.status()));
|
||||||
|
assert_eq!(status.targets.len(), 1);
|
||||||
|
let target = &status.targets[0];
|
||||||
|
assert!(target.desired);
|
||||||
|
assert_eq!(target.lifecycle, ManagedTargetLifecycle::Terminal);
|
||||||
|
let terminal = target
|
||||||
|
.outcome
|
||||||
|
.as_ref()
|
||||||
|
.expect("terminal details are durable");
|
||||||
|
assert_eq!(terminal.target, target.target);
|
||||||
|
assert_eq!(status.terminal_target_count, 1);
|
||||||
|
assert_eq!(status.total_target_outcomes, 1);
|
||||||
|
assert_eq!(status.successful_target_outcomes, 1);
|
||||||
|
assert_eq!(status.failed_target_outcomes, 0);
|
||||||
|
assert_eq!(status.peer_closed_target_outcomes, 0);
|
||||||
|
assert_eq!(status.discarded_target_outcomes, 0);
|
||||||
|
assert_eq!(
|
||||||
|
status.recent_target_outcomes.as_ref(),
|
||||||
|
&[terminal.as_ref().clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status.recent_target_outcomes,
|
||||||
|
outcome.recent_target_outcomes
|
||||||
|
);
|
||||||
server.finish();
|
server.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,16 +544,32 @@ fn dropping_all_handles_does_not_stop() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_drop_abandonment() {
|
fn task_drop_abandonment() {
|
||||||
let (task, handle) = ManagedClient::task(
|
// A latched but unobserved stop must not turn abandonment into completion.
|
||||||
config(ManagedPacing::Staggered),
|
for request_stop in [false, true] {
|
||||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
let (task, handle) = ManagedClient::task(
|
||||||
)
|
config(ManagedPacing::Staggered),
|
||||||
.unwrap();
|
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||||
let mut events = handle.subscribe().unwrap();
|
)
|
||||||
drop(task);
|
.unwrap();
|
||||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
let mut events = handle.subscribe().unwrap();
|
||||||
assert_eq!(handle.status().total_target_outcomes, 0);
|
let mut receipt = request_stop.then(|| Box::pin(handle.stop()));
|
||||||
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
if let Some(receipt) = &mut receipt {
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
}
|
||||||
|
drop(task);
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||||
|
assert_eq!(handle.status().total_target_outcomes, 0);
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
|
assert!(handle.status().final_outcome.is_none());
|
||||||
|
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
||||||
|
if let Some(receipt) = &mut receipt {
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
}
|
||||||
|
let mut late_stop = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(late_stop.as_mut()).is_ready());
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ fn backend_basic_open_echo_close() {
|
|||||||
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
|
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
|
||||||
|
|
||||||
let sent = client.send_probe().unwrap();
|
let sent = client.send_probe().unwrap();
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.seq, 0);
|
||||||
|
|
||||||
let events = client.recv_once().unwrap();
|
let events = client.recv_once().unwrap();
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
|
|||||||
@@ -243,8 +243,7 @@ where
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let negotiated = assert_started(client.open().unwrap());
|
let negotiated = assert_started(client.open().unwrap());
|
||||||
|
|
||||||
let sent_events = client.send_probe().unwrap();
|
let receipt = client.send_probe().unwrap();
|
||||||
assert_eq!(sent_events.len(), 1);
|
|
||||||
|
|
||||||
let reply_events = client.recv_once().unwrap();
|
let reply_events = client.recv_once().unwrap();
|
||||||
assert_eq!(reply_events.len(), 1);
|
assert_eq!(reply_events.len(), 1);
|
||||||
@@ -254,7 +253,7 @@ where
|
|||||||
|
|
||||||
OneProbeRun {
|
OneProbeRun {
|
||||||
negotiated,
|
negotiated,
|
||||||
sent: sent_events.into_iter().next().unwrap(),
|
sent: receipt.into(),
|
||||||
reply: reply_events.into_iter().next().unwrap(),
|
reply: reply_events.into_iter().next().unwrap(),
|
||||||
observations,
|
observations,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-proto"
|
name = "irtt-proto"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
description = "IRTT protocol encoding and decoding primitives"
|
description = "IRTT protocol encoding and decoding primitives"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "protocol"]
|
keywords = ["irtt", "networking", "latency", "protocol"]
|
||||||
categories = ["network-programming"]
|
categories = ["network-programming"]
|
||||||
@@ -18,4 +17,4 @@ subtle = "2.6.1"
|
|||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1.11.0"
|
proptest = "1.12.0"
|
||||||
@@ -304,172 +304,3 @@ fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
|
|||||||
*pos += TIMESTAMP_SIZE;
|
*pos += TIMESTAMP_SIZE;
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
hmac,
|
|
||||||
params::{Clock, ReceivedStats, StampAt},
|
|
||||||
write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn default_params() -> Params {
|
|
||||||
Params {
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn params_with_payload_space(payload_space: usize) -> Params {
|
|
||||||
let header_len = PacketLayout::echo(false, &Params::default()).header_len();
|
|
||||||
Params {
|
|
||||||
length: (header_len + payload_space) as i64,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zero-fills every negotiated reply field, matching what a peer that
|
|
||||||
/// reports no optional values would put on the wire.
|
|
||||||
fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
|
|
||||||
if layout.recv_count {
|
|
||||||
out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
|
|
||||||
}
|
|
||||||
if layout.recv_window {
|
|
||||||
out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
|
|
||||||
}
|
|
||||||
for _ in 0..layout.timestamp_count() {
|
|
||||||
out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_decodes_default_fields() {
|
|
||||||
let packet = [
|
|
||||||
0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
|
|
||||||
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
|
|
||||||
0x00, 0x00, 0x00, 0x00,
|
|
||||||
];
|
|
||||||
let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.sequence, 2);
|
|
||||||
assert_eq!(reply.recv_count, Some(3));
|
|
||||||
assert_eq!(reply.recv_window, Some(7));
|
|
||||||
assert!(reply.timestamps.recv_wall.is_some());
|
|
||||||
assert!(reply.timestamps.midpoint_wall.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
||||||
let params = params_with_payload_space(4);
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, None).unwrap();
|
|
||||||
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_rejects_shorter_than_negotiated_length() {
|
|
||||||
let params = params_with_payload_space(4);
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, None),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 20,
|
|
||||||
actual: 16,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_rejects_longer_than_negotiated_length() {
|
|
||||||
let params = Params::default();
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
packet.push(0);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, None),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 16,
|
|
||||||
actual: 17,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_decodes_default_fields_after_hmac() {
|
|
||||||
let params = default_params();
|
|
||||||
let layout = PacketLayout::echo(true, ¶ms);
|
|
||||||
let mut packet = Vec::with_capacity(layout.header_len());
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
push_zeroed_layout_tail(layout, &mut packet);
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(packet.len(), 76);
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.sequence, 2);
|
|
||||||
assert_eq!(reply.recv_count, Some(0));
|
|
||||||
assert_eq!(reply.recv_window, Some(0));
|
|
||||||
assert_eq!(reply.payload.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
||||||
let mut params = Params {
|
|
||||||
length: 48,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
params.received_stats = ReceivedStats::Both;
|
|
||||||
let layout = PacketLayout::echo(true, ¶ms);
|
|
||||||
let mut packet = Vec::with_capacity(echo_packet_len(true, ¶ms).unwrap());
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
push_zeroed_layout_tail(layout, &mut packet);
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
|
|
||||||
let params = Params::default();
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
packet.push(0);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, Some(b"testkey")),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 32,
|
|
||||||
actual: 33,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -136,101 +136,3 @@ fn with_hmac_flag(flags: u8, authenticated: bool) -> u8 {
|
|||||||
flags & !FLAG_HMAC
|
flags & !FLAG_HMAC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
flags::{FLAG_OPEN, FLAG_REPLY},
|
|
||||||
MAGIC,
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEY: &[u8] = b"testkey";
|
|
||||||
const OPEN_REQUEST_RULES: &[FlagRule] =
|
|
||||||
&[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)];
|
|
||||||
|
|
||||||
fn header(flags: u8) -> Vec<u8> {
|
|
||||||
let mut packet = MAGIC.to_vec();
|
|
||||||
packet.push(flags);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_bad_magic_and_reserved_flags() {
|
|
||||||
assert_eq!(
|
|
||||||
decode(&[0, MAGIC[1], MAGIC[2], FLAG_OPEN], None, &[]),
|
|
||||||
Err(ProtoError::BadMagic)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN | 0x10), None, &[]),
|
|
||||||
Err(ProtoError::ReservedFlags(0x10))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_enforces_required_and_rejected_flags() {
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(0), None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::MissingFlag(FLAG_OPEN))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN | FLAG_REPLY), None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_hmac_presence_mismatch() {
|
|
||||||
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
authenticated.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&authenticated, None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::HmacPresenceMismatch)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN), Some(KEY), OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::HmacPresenceMismatch)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structural_decode_reports_hmac_presence_without_a_key() {
|
|
||||||
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
authenticated.extend_from_slice(&[0xff; HMAC_SIZE]);
|
|
||||||
|
|
||||||
let envelope = decode_structural(&authenticated).unwrap();
|
|
||||||
assert!(envelope.hmac_present);
|
|
||||||
assert_eq!(envelope.body_offset, HEADER_SIZE + HMAC_SIZE);
|
|
||||||
|
|
||||||
let envelope = decode_structural(&header(FLAG_OPEN)).unwrap();
|
|
||||||
assert!(!envelope.hmac_present);
|
|
||||||
assert_eq!(envelope.body_offset, HEADER_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_rejects_bad_hmac() {
|
|
||||||
let mut packet = begin_checked(FLAG_OPEN, Some(KEY), OPEN_REQUEST_RULES, 22).unwrap();
|
|
||||||
packet.extend_from_slice(&[1, 2]);
|
|
||||||
let mut packet = finish(packet, Some(KEY)).unwrap();
|
|
||||||
*packet.last_mut().unwrap() ^= 0x80;
|
|
||||||
|
|
||||||
decode(&packet, Some(KEY), OPEN_REQUEST_RULES).unwrap();
|
|
||||||
assert_eq!(verify(&packet, Some(KEY)), Err(ProtoError::BadHmac));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoder_key_authoritatively_normalizes_hmac_flag() {
|
|
||||||
let plain =
|
|
||||||
begin_checked(FLAG_OPEN | FLAG_HMAC, None, OPEN_REQUEST_RULES, HEADER_SIZE).unwrap();
|
|
||||||
assert_eq!(plain[3], FLAG_OPEN);
|
|
||||||
|
|
||||||
let authenticated = begin_checked(
|
|
||||||
FLAG_OPEN,
|
|
||||||
Some(KEY),
|
|
||||||
OPEN_REQUEST_RULES,
|
|
||||||
HEADER_SIZE + HMAC_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(authenticated[3], FLAG_OPEN | FLAG_HMAC);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,76 +96,3 @@ pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
|
|||||||
pub(crate) fn hmac_offset() -> usize {
|
pub(crate) fn hmac_offset() -> usize {
|
||||||
HEADER_SIZE
|
HEADER_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
flags::{FLAG_HMAC, FLAG_OPEN},
|
|
||||||
MAGIC,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn reference_copy_zero_hmac(
|
|
||||||
key: &[u8],
|
|
||||||
packet: &[u8],
|
|
||||||
hmac_offset: usize,
|
|
||||||
) -> Result<[u8; HMAC_SIZE]> {
|
|
||||||
if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE {
|
|
||||||
return Err(ProtoError::InvalidHmacOffset);
|
|
||||||
}
|
|
||||||
let mut copy = packet.to_vec();
|
|
||||||
copy[hmac_offset..hmac_offset + HMAC_SIZE].fill(0);
|
|
||||||
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC accepts keys of any size");
|
|
||||||
mac.update(©);
|
|
||||||
let bytes = mac.finalize().into_bytes();
|
|
||||||
let mut out = [0u8; HMAC_SIZE];
|
|
||||||
out.copy_from_slice(&bytes);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packet_with_hmac_field(hmac_bytes: [u8; HMAC_SIZE]) -> Vec<u8> {
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
|
||||||
packet.push(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&hmac_bytes);
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_and_verify_hmac() {
|
|
||||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
|
||||||
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
verify_hmac(b"wrong", &packet, hmac_offset()),
|
|
||||||
Err(ProtoError::BadHmac)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_hmac_matches_copy_and_zero_semantics() {
|
|
||||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
|
|
||||||
|
|
||||||
let digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
let reference = reference_copy_zero_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(digest, reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_hmac_rejects_modified_authenticated_byte() {
|
|
||||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
packet[hmac_offset() + HMAC_SIZE + 1] ^= 0x01;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()),
|
|
||||||
Err(ProtoError::BadHmac)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -162,194 +162,3 @@ pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
|
|||||||
};
|
};
|
||||||
Ok(header_len.max(requested))
|
Ok(header_len.max(requested))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::params::{Clock, ReceivedStats, StampAt};
|
|
||||||
|
|
||||||
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
|
|
||||||
Params {
|
|
||||||
received_stats: stats,
|
|
||||||
stamp_at,
|
|
||||||
clock,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
|
|
||||||
let stats_len = match stats {
|
|
||||||
ReceivedStats::None => 0,
|
|
||||||
ReceivedStats::Count => RECV_COUNT_SIZE,
|
|
||||||
ReceivedStats::Window => RECV_WINDOW_SIZE,
|
|
||||||
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
|
||||||
};
|
|
||||||
let clock_count = match clock {
|
|
||||||
Clock::Unspecified => 0,
|
|
||||||
Clock::Wall | Clock::Monotonic => 1,
|
|
||||||
Clock::Both => 2,
|
|
||||||
};
|
|
||||||
let timestamp_groups = match stamp_at {
|
|
||||||
StampAt::None => 0,
|
|
||||||
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
|
|
||||||
StampAt::Both => 2,
|
|
||||||
};
|
|
||||||
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verified_layout_lengths() {
|
|
||||||
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
|
||||||
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
|
||||||
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
|
||||||
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::None, StampAt::None, Clock::Both)
|
|
||||||
),
|
|
||||||
16
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Count, StampAt::Send, Clock::Wall)
|
|
||||||
),
|
|
||||||
28
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Window, StampAt::Receive, Clock::Monotonic)
|
|
||||||
),
|
|
||||||
32
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Midpoint, Clock::Both)
|
|
||||||
),
|
|
||||||
44
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both)
|
|
||||||
),
|
|
||||||
60
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
true,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both)
|
|
||||||
),
|
|
||||||
76
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
|
||||||
for stats in [
|
|
||||||
ReceivedStats::None,
|
|
||||||
ReceivedStats::Count,
|
|
||||||
ReceivedStats::Window,
|
|
||||||
ReceivedStats::Both,
|
|
||||||
] {
|
|
||||||
for stamp_at in [
|
|
||||||
StampAt::None,
|
|
||||||
StampAt::Send,
|
|
||||||
StampAt::Receive,
|
|
||||||
StampAt::Both,
|
|
||||||
StampAt::Midpoint,
|
|
||||||
] {
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
|
||||||
for hmac in [false, true] {
|
|
||||||
let params = params(stats, stamp_at, clock);
|
|
||||||
let layout = PacketLayout::echo(hmac, ¶ms);
|
|
||||||
let expected_len = HEADER_SIZE
|
|
||||||
+ TOKEN_SIZE
|
|
||||||
+ SEQ_SIZE
|
|
||||||
+ if hmac { HMAC_SIZE } else { 0 }
|
|
||||||
+ expected_optional_len(stats, stamp_at, clock);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
layout.header_len(),
|
|
||||||
expected_len,
|
|
||||||
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
|
|
||||||
);
|
|
||||||
assert_eq!(layout.recv_count, stats.has_count());
|
|
||||||
assert_eq!(layout.recv_window, stats.has_window());
|
|
||||||
assert_eq!(
|
|
||||||
layout.recv_wall,
|
|
||||||
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
|
||||||
&& clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.recv_mono,
|
|
||||||
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
|
||||||
&& clock.has_mono()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.midpoint_wall,
|
|
||||||
matches!(stamp_at, StampAt::Midpoint) && clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.midpoint_mono,
|
|
||||||
matches!(stamp_at, StampAt::Midpoint) && clock.has_mono()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.send_wall,
|
|
||||||
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.send_mono,
|
|
||||||
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_mono()
|
|
||||||
);
|
|
||||||
|
|
||||||
if hmac {
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(true, ¶ms) - echo_header_len(false, ¶ms),
|
|
||||||
HMAC_SIZE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiated_length_is_floored_at_the_required_field_block() {
|
|
||||||
// The 60-byte header of this layout is the floor. A negative negotiated
|
|
||||||
// length is not an error: it asks for nothing beyond the mandatory
|
|
||||||
// fields, exactly as zero does.
|
|
||||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
|
||||||
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
|
|
||||||
p.length = length;
|
|
||||||
assert_eq!(
|
|
||||||
echo_packet_len(false, &p),
|
|
||||||
Ok(expected),
|
|
||||||
"unexpected packet length for negotiated length {length}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A positive length wider than `usize` must stay an error rather than
|
|
||||||
/// becoming a saturated buffer size. Only reachable where `usize` is
|
|
||||||
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
|
|
||||||
#[cfg(target_pointer_width = "32")]
|
|
||||||
#[test]
|
|
||||||
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
|
|
||||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
|
||||||
p.length = 5_000_000_000;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
echo_packet_len(false, &p),
|
|
||||||
Err(ProtoError::PacketLengthUnrepresentable {
|
|
||||||
length: 5_000_000_000
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -77,6 +77,9 @@
|
|||||||
//!
|
//!
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
/// Cargo package version of this compiled library.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
pub mod echo;
|
pub mod echo;
|
||||||
mod envelope;
|
mod envelope;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -98,53 +98,3 @@ pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenR
|
|||||||
params: Params::decode(&packet[pos..])?,
|
params: Params::decode(&packet[pos..])?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
hmac,
|
|
||||||
params::{Clock, ReceivedStats, StampAt},
|
|
||||||
FLAG_HMAC, HMAC_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn default_params() -> Params {
|
|
||||||
Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 3_000_000_000,
|
|
||||||
interval_ns: 1_000_000_000,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_reply_decodes_token() {
|
|
||||||
let mut packet = vec![0x14, 0xa7, 0x5b, 0x03];
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&default_params().encode());
|
|
||||||
|
|
||||||
let reply = decode_open_reply(&packet, None).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.params, default_params());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_open_reply_decodes_token_after_hmac_field() {
|
|
||||||
let mut packet = vec![0x14, 0xa7, 0x5b, FLAG_OPEN | FLAG_REPLY | FLAG_HMAC];
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&default_params().encode());
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
packet.len(),
|
|
||||||
4 + HMAC_SIZE + TOKEN_SIZE + default_params().encode().len()
|
|
||||||
);
|
|
||||||
let reply = decode_open_reply(&packet, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.params, default_params());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -368,773 +368,3 @@ fn read_int(input: &[u8], pos: &mut usize) -> Result<i64> {
|
|||||||
*pos += used;
|
*pos += used;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn assert_round_trip(params: Params) {
|
|
||||||
assert_eq!(Params::decode(¶ms.encode()), Ok(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(9, &mut encoded);
|
|
||||||
varint::encode_uvarint(value.len() as u64, &mut encoded);
|
|
||||||
encoded.extend_from_slice(value);
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(tag, &mut encoded);
|
|
||||||
varint::encode_varint(value, &mut encoded);
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 3_000_000_000,
|
|
||||||
interval_ns: 1_000_000_000,
|
|
||||||
length: 1472,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
dscp: 184,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "pattern:abc".to_owned(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_negotiated_option_modes() {
|
|
||||||
for received_stats in [
|
|
||||||
ReceivedStats::None,
|
|
||||||
ReceivedStats::Count,
|
|
||||||
ReceivedStats::Window,
|
|
||||||
ReceivedStats::Both,
|
|
||||||
] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
received_stats,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for stamp_at in [
|
|
||||||
StampAt::None,
|
|
||||||
StampAt::Send,
|
|
||||||
StampAt::Receive,
|
|
||||||
StampAt::Both,
|
|
||||||
StampAt::Midpoint,
|
|
||||||
] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
stamp_at,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_dscp_values_are_encoded_without_shifting() {
|
|
||||||
// `Params::dscp` is a raw wire byte; encode/decode carry whatever
|
|
||||||
// value is set without interpreting or shifting it, regardless of
|
|
||||||
// whether it happens to look like a codepoint (46) or a raw TOS byte
|
|
||||||
// (184).
|
|
||||||
for dscp in [0, 46, 63, 64, 184, -1] {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
dscp,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
dscp: 46,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
assert!(
|
|
||||||
encoded.windows(2).any(|bytes| bytes == [8, 92]),
|
|
||||||
"dscp value 46 must be encoded as param value 46"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!encoded.windows(3).any(|bytes| bytes == [8, 0xf0, 0x02]),
|
|
||||||
"Params::encode must not shift dscp 46 to 184; any codepoint shift is a caller concern"
|
|
||||||
);
|
|
||||||
assert_eq!(Params::decode(&encoded).unwrap().dscp, 46);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_absent_short_and_max_length_round_trip() {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: None,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_decode_accepts_max_length() {
|
|
||||||
let value = b"0123456789abcdef0123456789abcdef";
|
|
||||||
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
params.server_fill,
|
|
||||||
Some(ServerFill {
|
|
||||||
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_decode_rejects_oversized_length() {
|
|
||||||
let value = b"0123456789abcdef0123456789abcdefx";
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encode_server_fill_value(value)),
|
|
||||||
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
assert!(encoded
|
|
||||||
.windows(6)
|
|
||||||
.any(|bytes| bytes == [9, 4, b'r', b'a', b'n', b'd']));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_tags_are_ignored() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(99, &mut encoded);
|
|
||||||
varint::encode_varint(123, &mut encoded);
|
|
||||||
varint::encode_uvarint(1, &mut encoded);
|
|
||||||
varint::encode_varint(1, &mut encoded);
|
|
||||||
|
|
||||||
let params = Params::decode(&encoded).unwrap();
|
|
||||||
assert_eq!(params.protocol_version, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_received_stats_value_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(5, &mut encoded);
|
|
||||||
varint::encode_varint(4, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "ReceivedStats",
|
|
||||||
value: 4,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_timestamp_value_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(6, &mut encoded);
|
|
||||||
varint::encode_varint(5, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "StampAt",
|
|
||||||
value: 5,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_clock_zero_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(7, &mut encoded);
|
|
||||||
varint::encode_varint(0, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "Clock",
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode_with_presence(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "Clock",
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_clock_values_one_to_three_are_accepted() {
|
|
||||||
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
|
|
||||||
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
|
|
||||||
assert_eq!(decoded.params.clock, expected);
|
|
||||||
assert!(decoded.presence.clock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
|
|
||||||
let decoded = Params::decode_with_presence(&[]).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.params, Params::default());
|
|
||||||
assert_eq!(decoded.params.protocol_version, 0);
|
|
||||||
assert_eq!(decoded.params.duration_ns, 0);
|
|
||||||
assert_eq!(decoded.params.interval_ns, 0);
|
|
||||||
assert_eq!(decoded.params.length, 0);
|
|
||||||
assert_eq!(decoded.params.dscp, 0);
|
|
||||||
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
|
|
||||||
assert_eq!(decoded.params.stamp_at, StampAt::None);
|
|
||||||
// The wire default clock is zero, not `Both`.
|
|
||||||
assert_eq!(decoded.params.clock, Clock::Unspecified);
|
|
||||||
assert!(!decoded.params.clock.has_wall());
|
|
||||||
assert!(!decoded.params.clock.has_mono());
|
|
||||||
assert_eq!(decoded.params.server_fill, None);
|
|
||||||
|
|
||||||
assert_eq!(decoded.presence, ParamPresence::default());
|
|
||||||
assert_eq!(
|
|
||||||
decoded.presence,
|
|
||||||
ParamPresence {
|
|
||||||
protocol_version: false,
|
|
||||||
duration_ns: false,
|
|
||||||
interval_ns: false,
|
|
||||||
length: false,
|
|
||||||
received_stats: false,
|
|
||||||
stamp_at: false,
|
|
||||||
clock: false,
|
|
||||||
dscp: false,
|
|
||||||
server_fill: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
|
|
||||||
// Duration and Interval are the pair a server must tell apart: absent is
|
|
||||||
// accepted, explicitly zero is not.
|
|
||||||
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
|
|
||||||
assert_eq!(absent.params.duration_ns, 0);
|
|
||||||
assert!(!absent.presence.duration_ns);
|
|
||||||
assert_eq!(absent.params.interval_ns, 0);
|
|
||||||
assert!(!absent.presence.interval_ns);
|
|
||||||
assert_eq!(absent.params.clock, Clock::Unspecified);
|
|
||||||
assert!(!absent.presence.clock);
|
|
||||||
|
|
||||||
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
|
|
||||||
assert_eq!(explicit_duration.params.duration_ns, 0);
|
|
||||||
assert!(explicit_duration.presence.duration_ns);
|
|
||||||
|
|
||||||
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
|
|
||||||
assert_eq!(explicit_interval.params.interval_ns, 0);
|
|
||||||
assert!(explicit_interval.presence.interval_ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_known_tag_reports_its_own_presence() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
for (tag, value) in [
|
|
||||||
(1, 1),
|
|
||||||
(2, 5),
|
|
||||||
(3, 6),
|
|
||||||
(4, 7),
|
|
||||||
(5, 3),
|
|
||||||
(6, 4),
|
|
||||||
(7, 3),
|
|
||||||
(8, 184),
|
|
||||||
] {
|
|
||||||
encoded.extend_from_slice(&encode_int(tag, value));
|
|
||||||
}
|
|
||||||
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
|
|
||||||
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
decoded.presence,
|
|
||||||
ParamPresence {
|
|
||||||
protocol_version: true,
|
|
||||||
duration_ns: true,
|
|
||||||
interval_ns: true,
|
|
||||||
length: true,
|
|
||||||
received_stats: true,
|
|
||||||
stamp_at: true,
|
|
||||||
clock: true,
|
|
||||||
dscp: true,
|
|
||||||
server_fill: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unspecified_clock_is_encoded_by_omission() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
clock: Clock::Unspecified,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
|
|
||||||
assert_eq!(encoded, encode_int(1, 1));
|
|
||||||
assert!(
|
|
||||||
!encoded.windows(2).any(|bytes| bytes[0] == 7),
|
|
||||||
"an unspecified clock must omit the tag rather than encode an explicit zero"
|
|
||||||
);
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_and_decode_with_presence_agree() {
|
|
||||||
let mut payloads = vec![
|
|
||||||
Vec::new(),
|
|
||||||
encode_int(1, 1),
|
|
||||||
encode_int(2, 0),
|
|
||||||
encode_server_fill_value(b"rand"),
|
|
||||||
];
|
|
||||||
payloads.push(
|
|
||||||
Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 3_000_000_000,
|
|
||||||
interval_ns: 1_000_000_000,
|
|
||||||
length: 1472,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
dscp: 184,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
.encode(),
|
|
||||||
);
|
|
||||||
// Malformed payloads must fail identically through both entry points.
|
|
||||||
payloads.push(vec![1, 0x80]);
|
|
||||||
payloads.push(encode_int(7, 0));
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&payload),
|
|
||||||
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
|
|
||||||
"decode and decode_with_presence disagreed on {payload:02x?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_server_fill_is_rejected() {
|
|
||||||
let encoded = [9, 4, b'a', b'b'];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::MalformedParams));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_utf8_server_fill_is_rejected() {
|
|
||||||
let encoded = [9, 1, 0xff];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::InvalidUtf8));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_pointer_width = "32")]
|
|
||||||
#[test]
|
|
||||||
fn server_fill_length_too_large_for_usize_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(9, &mut encoded);
|
|
||||||
varint::encode_uvarint(u64::from(u32::MAX) + 1, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::ParameterLengthTooLarge {
|
|
||||||
tag: 9,
|
|
||||||
length: u64::from(u32::MAX) + 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_varint_parameter_is_rejected() {
|
|
||||||
let encoded = [1, 0x80];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_unknown_parameter_value_is_rejected() {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,112 +59,3 @@ pub fn decode_varint(input: &[u8]) -> Result<(i64, usize)> {
|
|||||||
let (value, used) = decode_uvarint(input)?;
|
let (value, used) = decode_uvarint(input)?;
|
||||||
Ok((zigzag_decode(value), used))
|
Ok((zigzag_decode(value), used))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uvarint_round_trips() {
|
|
||||||
for value in [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
127,
|
|
||||||
128,
|
|
||||||
255,
|
|
||||||
16_384,
|
|
||||||
u32::MAX as u64,
|
|
||||||
i64::MAX as u64,
|
|
||||||
u64::MAX,
|
|
||||||
] {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_uvarint(value, &mut encoded);
|
|
||||||
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signed_varint_round_trips() {
|
|
||||||
for value in [
|
|
||||||
i64::MIN,
|
|
||||||
-9_223_372_036,
|
|
||||||
-2,
|
|
||||||
-1,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
1_000_000_000,
|
|
||||||
3_000_000_000,
|
|
||||||
i64::MAX,
|
|
||||||
] {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_varint(value, &mut encoded);
|
|
||||||
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verified_byte_examples() {
|
|
||||||
let cases: &[(i64, &[u8])] = &[
|
|
||||||
(1, &[0x02]),
|
|
||||||
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
|
|
||||||
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
|
|
||||||
(1472, &[0x80, 0x17]),
|
|
||||||
(3, &[0x06]),
|
|
||||||
(184, &[0xf0, 0x02]),
|
|
||||||
];
|
|
||||||
for (value, expected) in cases {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_varint(*value, &mut encoded);
|
|
||||||
assert_eq!(&encoded, expected);
|
|
||||||
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tag = Vec::new();
|
|
||||||
encode_uvarint(9, &mut tag);
|
|
||||||
assert_eq!(tag, [0x09]);
|
|
||||||
tag.clear();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use irtt_proto::{compute_hmac, compute_hmac_in_place, verify_hmac, ProtoError};
|
||||||
|
|
||||||
|
fn captured_open() -> Vec<u8> {
|
||||||
|
// Captured vector 6; provenance and independently checked digest are in
|
||||||
|
// docs/protocol/test-vectors/README.md. This is an oracle for the primitive.
|
||||||
|
"14 a7 5b 09 ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36
|
||||||
|
01 02 02 80 d0 ac f3 0e 03 80 a8 d6 b9 07 05 06 06 06 07 06"
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computing_hmac_ignores_existing_digest_and_only_replaces_its_field() {
|
||||||
|
let expected = captured_open();
|
||||||
|
let mut packet = expected.clone();
|
||||||
|
packet[4..20].fill(0xa5);
|
||||||
|
let before = packet.clone();
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac(b"testkey", &packet, 4).unwrap().as_slice(),
|
||||||
|
&expected[4..20]
|
||||||
|
);
|
||||||
|
assert_eq!(packet, before);
|
||||||
|
compute_hmac_in_place(b"testkey", &mut packet, 4).unwrap();
|
||||||
|
assert_eq!(packet, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verification_rejects_wrong_key_and_modified_authenticated_bytes() {
|
||||||
|
let packet = captured_open();
|
||||||
|
verify_hmac(b"testkey", &packet, 4).unwrap();
|
||||||
|
assert_eq!(verify_hmac(b"wrong", &packet, 4), Err(ProtoError::BadHmac));
|
||||||
|
for offset in [0, 4, 21, packet.len() - 1] {
|
||||||
|
let mut modified = packet.clone();
|
||||||
|
modified[offset] ^= 1;
|
||||||
|
assert_eq!(
|
||||||
|
verify_hmac(b"testkey", &modified, 4),
|
||||||
|
Err(ProtoError::BadHmac)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_or_out_of_bounds_digest_fields_are_rejected_without_mutation() {
|
||||||
|
for offset in [5, 20, 21, usize::MAX] {
|
||||||
|
let mut packet = vec![0xa5; 20];
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac(b"key", &packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verify_hmac(b"key", &packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac_in_place(b"key", &mut packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(packet, [0xa5; 20]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use irtt_proto::params::{Clock, ReceivedStats, StampAt};
|
||||||
|
use irtt_proto::{
|
||||||
|
echo_header_len, echo_packet_len, PacketLayout, Params, HMAC_SIZE, RECV_COUNT_SIZE,
|
||||||
|
RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEADER_SIZE: usize = 4;
|
||||||
|
|
||||||
|
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
|
||||||
|
Params {
|
||||||
|
received_stats: stats,
|
||||||
|
stamp_at,
|
||||||
|
clock,
|
||||||
|
..Params::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
|
||||||
|
let stats_len = match stats {
|
||||||
|
ReceivedStats::None => 0,
|
||||||
|
ReceivedStats::Count => RECV_COUNT_SIZE,
|
||||||
|
ReceivedStats::Window => RECV_WINDOW_SIZE,
|
||||||
|
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
||||||
|
};
|
||||||
|
let clock_count = match clock {
|
||||||
|
Clock::Unspecified => 0,
|
||||||
|
Clock::Wall | Clock::Monotonic => 1,
|
||||||
|
Clock::Both => 2,
|
||||||
|
};
|
||||||
|
let timestamp_groups = match stamp_at {
|
||||||
|
StampAt::None => 0,
|
||||||
|
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
|
||||||
|
StampAt::Both => 2,
|
||||||
|
};
|
||||||
|
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_and_close_layout_lengths() {
|
||||||
|
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
||||||
|
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
||||||
|
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
||||||
|
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
||||||
|
for stats in [
|
||||||
|
ReceivedStats::None,
|
||||||
|
ReceivedStats::Count,
|
||||||
|
ReceivedStats::Window,
|
||||||
|
ReceivedStats::Both,
|
||||||
|
] {
|
||||||
|
for stamp_at in [
|
||||||
|
StampAt::None,
|
||||||
|
StampAt::Send,
|
||||||
|
StampAt::Receive,
|
||||||
|
StampAt::Both,
|
||||||
|
StampAt::Midpoint,
|
||||||
|
] {
|
||||||
|
for clock in [
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
] {
|
||||||
|
for hmac in [false, true] {
|
||||||
|
let params = params(stats, stamp_at, clock);
|
||||||
|
let layout = PacketLayout::echo(hmac, ¶ms);
|
||||||
|
let expected_len = HEADER_SIZE
|
||||||
|
+ TOKEN_SIZE
|
||||||
|
+ SEQ_SIZE
|
||||||
|
+ if hmac { HMAC_SIZE } else { 0 }
|
||||||
|
+ expected_optional_len(stats, stamp_at, clock);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
layout.header_len(),
|
||||||
|
expected_len,
|
||||||
|
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_count,
|
||||||
|
matches!(stats, ReceivedStats::Count | ReceivedStats::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_window,
|
||||||
|
matches!(stats, ReceivedStats::Window | ReceivedStats::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_wall,
|
||||||
|
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_mono,
|
||||||
|
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.midpoint_wall,
|
||||||
|
matches!(stamp_at, StampAt::Midpoint)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.midpoint_mono,
|
||||||
|
matches!(stamp_at, StampAt::Midpoint)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.send_wall,
|
||||||
|
matches!(stamp_at, StampAt::Send | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.send_mono,
|
||||||
|
matches!(stamp_at, StampAt::Send | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
|
||||||
|
if hmac {
|
||||||
|
assert_eq!(
|
||||||
|
echo_header_len(true, ¶ms) - echo_header_len(false, ¶ms),
|
||||||
|
HMAC_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negotiated_length_is_floored_at_the_required_field_block() {
|
||||||
|
// The 60-byte header of this layout is the floor. A negative negotiated
|
||||||
|
// length is not an error: it asks for nothing beyond the mandatory
|
||||||
|
// fields, exactly as zero does.
|
||||||
|
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||||
|
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
|
||||||
|
p.length = length;
|
||||||
|
assert_eq!(
|
||||||
|
echo_packet_len(false, &p),
|
||||||
|
Ok(expected),
|
||||||
|
"unexpected packet length for negotiated length {length}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A positive length wider than `usize` must stay an error rather than
|
||||||
|
/// becoming a saturated buffer size. Only reachable where `usize` is
|
||||||
|
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
|
||||||
|
#[cfg(target_pointer_width = "32")]
|
||||||
|
#[test]
|
||||||
|
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
|
||||||
|
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||||
|
p.length = 5_000_000_000;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
echo_packet_len(false, &p),
|
||||||
|
Err(irtt_proto::ProtoError::PacketLengthUnrepresentable {
|
||||||
|
length: 5_000_000_000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
use irtt_proto::{
|
||||||
|
varint, Clock, ParamPresence, Params, ProtoError, ReceivedStats, ServerFill, StampAt,
|
||||||
|
};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn int(tag: u64, value: i64) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut out);
|
||||||
|
varint::encode_varint(value, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(value: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![9];
|
||||||
|
varint::encode_uvarint(value.len() as u64, &mut out);
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presence(p: ParamPresence) -> [bool; 9] {
|
||||||
|
[
|
||||||
|
p.protocol_version,
|
||||||
|
p.duration_ns,
|
||||||
|
p.interval_ns,
|
||||||
|
p.length,
|
||||||
|
p.received_stats,
|
||||||
|
p.stamp_at,
|
||||||
|
p.clock,
|
||||||
|
p.dscp,
|
||||||
|
p.server_fill,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_error(bytes: &[u8], expected: ProtoError) {
|
||||||
|
assert_eq!(Params::decode(bytes).unwrap_err(), expected, "{bytes:02x?}");
|
||||||
|
assert_eq!(
|
||||||
|
Params::decode_with_presence(bytes).unwrap_err(),
|
||||||
|
expected,
|
||||||
|
"{bytes:02x?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_encodings_preserve_raw_dscp_and_utf8_byte_lengths() {
|
||||||
|
for (params, bytes) in [
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
dscp: 46,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![8, 92],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
dscp: 184,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![8, 0xf0, 2],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: "rand".into(),
|
||||||
|
}),
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![9, 4, b'r', b'a', b'n', b'd'],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
server_fill: Some(ServerFill { value: "é".into() }),
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![9, 2, 0xc3, 0xa9],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(params.encode(), bytes);
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_defaults_and_explicit_zero_presence() {
|
||||||
|
let defaults = Params {
|
||||||
|
protocol_version: 0,
|
||||||
|
duration_ns: 0,
|
||||||
|
interval_ns: 0,
|
||||||
|
length: 0,
|
||||||
|
received_stats: ReceivedStats::None,
|
||||||
|
stamp_at: StampAt::None,
|
||||||
|
clock: Clock::Unspecified,
|
||||||
|
dscp: 0,
|
||||||
|
server_fill: None,
|
||||||
|
};
|
||||||
|
let absent = Params::decode_with_presence(&[]).unwrap();
|
||||||
|
assert_eq!(absent.params, defaults);
|
||||||
|
assert_eq!(presence(absent.presence), [false; 9]);
|
||||||
|
assert_eq!(defaults.encode(), Vec::<u8>::new());
|
||||||
|
assert!(!defaults.clock.has_wall());
|
||||||
|
assert!(!defaults.clock.has_mono());
|
||||||
|
|
||||||
|
// Clock is the sole integer tag for which explicit zero is invalid.
|
||||||
|
for tag in [1, 2, 3, 4, 5, 6, 8] {
|
||||||
|
let bytes = int(tag, 0);
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
assert_eq!(decoded.params, defaults);
|
||||||
|
let mut expected = [false; 9];
|
||||||
|
expected[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected, "tag {tag}");
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), decoded.params);
|
||||||
|
}
|
||||||
|
let empty_fill = Params::decode_with_presence(&[9, 0]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
empty_fill.params.server_fill,
|
||||||
|
Some(ServerFill {
|
||||||
|
value: String::new()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
presence(empty_fill.presence),
|
||||||
|
[false, false, false, false, false, false, false, false, true]
|
||||||
|
);
|
||||||
|
assert_eq!(empty_fill.params.encode(), [9, 0]);
|
||||||
|
assert_eq!(
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..defaults
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_tags_map_to_their_fields_and_report_isolated_presence() {
|
||||||
|
for (tag, value, expected) in [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
Params {
|
||||||
|
duration_ns: 5,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
6,
|
||||||
|
Params {
|
||||||
|
interval_ns: 6,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
7,
|
||||||
|
Params {
|
||||||
|
length: 7,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
184,
|
||||||
|
Params {
|
||||||
|
dscp: 184,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
|
||||||
|
assert_eq!(decoded.params, expected);
|
||||||
|
let mut expected_presence = [false; 9];
|
||||||
|
expected_presence[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected_presence);
|
||||||
|
}
|
||||||
|
for (tag, modes) in [
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
vec![
|
||||||
|
ReceivedStats::None as i64,
|
||||||
|
ReceivedStats::Count as i64,
|
||||||
|
ReceivedStats::Window as i64,
|
||||||
|
ReceivedStats::Both as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
6,
|
||||||
|
vec![
|
||||||
|
StampAt::None as i64,
|
||||||
|
StampAt::Send as i64,
|
||||||
|
StampAt::Receive as i64,
|
||||||
|
StampAt::Both as i64,
|
||||||
|
StampAt::Midpoint as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
7,
|
||||||
|
vec![
|
||||||
|
Clock::Wall as i64,
|
||||||
|
Clock::Monotonic as i64,
|
||||||
|
Clock::Both as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for (index, mode) in modes.into_iter().enumerate() {
|
||||||
|
let value = index as i64 + if tag == 7 { 1 } else { 0 };
|
||||||
|
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
|
||||||
|
let actual = match tag {
|
||||||
|
5 => decoded.params.received_stats as i64,
|
||||||
|
6 => decoded.params.stamp_at as i64,
|
||||||
|
7 => decoded.params.clock as i64,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
assert_eq!(actual, mode);
|
||||||
|
let mut expected_presence = [false; 9];
|
||||||
|
expected_presence[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected_presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_values_and_invalid_enums_are_rejected_by_both_decoders() {
|
||||||
|
for (tag, value, name) in [
|
||||||
|
(5, 4, "ReceivedStats"),
|
||||||
|
(6, 5, "StampAt"),
|
||||||
|
(7, 0, "Clock"),
|
||||||
|
(7, 4, "Clock"),
|
||||||
|
(5, -1, "ReceivedStats"),
|
||||||
|
(6, -1, "StampAt"),
|
||||||
|
(7, -1, "Clock"),
|
||||||
|
] {
|
||||||
|
assert_error(&int(tag, value), ProtoError::InvalidEnum { name, value });
|
||||||
|
}
|
||||||
|
for bytes in [&[1, 0x80][..], &[99], &[0x80], &[9, 0x80]] {
|
||||||
|
assert_error(bytes, ProtoError::TruncatedVarint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_fill_accepts_valid_bytes_and_enforces_length_and_utf8_boundaries() {
|
||||||
|
for value in [
|
||||||
|
"",
|
||||||
|
"rand",
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"éééééééééééééééé",
|
||||||
|
] {
|
||||||
|
let expected = Params {
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: value.into(),
|
||||||
|
}),
|
||||||
|
..Params::default()
|
||||||
|
};
|
||||||
|
let bytes = fill(value.as_bytes());
|
||||||
|
assert_eq!(expected.encode(), bytes);
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
assert_eq!(decoded.params, expected);
|
||||||
|
assert!(decoded.presence.server_fill);
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), expected);
|
||||||
|
}
|
||||||
|
assert_error(
|
||||||
|
&fill(b"0123456789abcdef0123456789abcdefx"),
|
||||||
|
ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 },
|
||||||
|
);
|
||||||
|
assert_error(&[9, 4, b'a', b'b'], ProtoError::MalformedParams);
|
||||||
|
assert_error(&[9, 1, 0xff], ProtoError::InvalidUtf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_pointer_width = "32")]
|
||||||
|
#[test]
|
||||||
|
fn server_fill_length_too_large_for_usize_is_rejected() {
|
||||||
|
let mut bytes = vec![9];
|
||||||
|
let length = u64::from(u32::MAX) + 1;
|
||||||
|
varint::encode_uvarint(length, &mut bytes);
|
||||||
|
assert_error(
|
||||||
|
&bytes,
|
||||||
|
ProtoError::ParameterLengthTooLarge { tag: 9, length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_strategy() -> impl Strategy<Value = String> {
|
||||||
|
// Eight arbitrary Unicode scalars occupy at most the allowed 32 bytes.
|
||||||
|
proptest::collection::vec(any::<char>(), 0..=8).prop_map(|chars| chars.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params_strategy() -> impl Strategy<Value = Params> {
|
||||||
|
(
|
||||||
|
any::<[i64; 5]>(),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
ReceivedStats::None,
|
||||||
|
ReceivedStats::Count,
|
||||||
|
ReceivedStats::Window,
|
||||||
|
ReceivedStats::Both,
|
||||||
|
]),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
StampAt::None,
|
||||||
|
StampAt::Send,
|
||||||
|
StampAt::Receive,
|
||||||
|
StampAt::Both,
|
||||||
|
StampAt::Midpoint,
|
||||||
|
]),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
]),
|
||||||
|
prop::option::of(string_strategy()),
|
||||||
|
)
|
||||||
|
.prop_map(
|
||||||
|
|(scalar, received_stats, stamp_at, clock, server_fill)| Params {
|
||||||
|
protocol_version: scalar[0],
|
||||||
|
duration_ns: scalar[1],
|
||||||
|
interval_ns: scalar[2],
|
||||||
|
length: scalar[3],
|
||||||
|
dscp: scalar[4],
|
||||||
|
received_stats,
|
||||||
|
stamp_at,
|
||||||
|
clock,
|
||||||
|
server_fill: server_fill.map(|value| ServerFill { value }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_tags(params: &Params) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// Deliberately bypass encode's omission: duplicate zero values must be present.
|
||||||
|
for (tag, value) in [
|
||||||
|
(1, params.protocol_version),
|
||||||
|
(2, params.duration_ns),
|
||||||
|
(3, params.interval_ns),
|
||||||
|
(4, params.length),
|
||||||
|
(5, params.received_stats as i64),
|
||||||
|
(6, params.stamp_at as i64),
|
||||||
|
(7, params.clock as i64),
|
||||||
|
(8, params.dscp),
|
||||||
|
] {
|
||||||
|
out.extend_from_slice(&int(tag, value));
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&fill(params.server_fill.as_ref().unwrap().value.as_bytes()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn valid_params_round_trip_with_omission_presence(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!(Params::decode(&encoded).unwrap(), params.clone());
|
||||||
|
prop_assert_eq!(presence(decoded.presence), [
|
||||||
|
params.protocol_version != 0, params.duration_ns != 0,
|
||||||
|
params.interval_ns != 0, params.length != 0,
|
||||||
|
params.received_stats != ReceivedStats::None, params.stamp_at != StampAt::None,
|
||||||
|
params.clock != Clock::Unspecified, params.dscp != 0, params.server_fill.is_some(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_known_tags_are_last_wins(
|
||||||
|
mut first in params_strategy(), mut second in params_strategy(),
|
||||||
|
first_clock in 1_i64..=3, second_clock in 1_i64..=3,
|
||||||
|
first_fill in string_strategy(), second_fill in string_strategy(),
|
||||||
|
) {
|
||||||
|
first.clock = Clock::try_from(first_clock).unwrap();
|
||||||
|
second.clock = Clock::try_from(second_clock).unwrap();
|
||||||
|
first.server_fill = Some(ServerFill { value: first_fill });
|
||||||
|
second.server_fill = Some(ServerFill { value: second_fill });
|
||||||
|
let mut bytes = all_tags(&first);
|
||||||
|
bytes.extend_from_slice(&all_tags(&second));
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
prop_assert_eq!(&decoded.params, &second);
|
||||||
|
prop_assert_eq!(Params::decode(&bytes).unwrap(), second);
|
||||||
|
prop_assert_eq!(presence(decoded.presence), [true; 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_scalar_tags_do_not_disturb_known_params(
|
||||||
|
params in params_strategy(), tag in 10_u64..=u16::MAX as u64, value: u64,
|
||||||
|
) {
|
||||||
|
let encoded = params.encode();
|
||||||
|
let expected = Params::decode_with_presence(&encoded).unwrap();
|
||||||
|
let mut unknown = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut unknown);
|
||||||
|
varint::encode_uvarint(value, &mut unknown);
|
||||||
|
for bytes in [[unknown.as_slice(), &encoded].concat(), [&encoded, unknown.as_slice()].concat()] {
|
||||||
|
prop_assert_eq!(Params::decode_with_presence(&bytes).unwrap(), expected.clone());
|
||||||
|
prop_assert_eq!(Params::decode(&bytes).unwrap(), params.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+223
-171
@@ -54,33 +54,41 @@ fn echo_reply(params: &Params, flags: u8) -> EchoReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn authenticated_reply_encoders_set_hmac_from_key() {
|
fn reply_encoders_normalize_hmac_flags_from_the_key() {
|
||||||
let params = params();
|
let params = params();
|
||||||
let open_reply = OpenReply {
|
for key in [None, Some(KEY)] {
|
||||||
flags: FLAG_OPEN | FLAG_REPLY,
|
for supplied_hmac in [0, FLAG_HMAC] {
|
||||||
token: TOKEN,
|
let auth = if key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
params: params.clone(),
|
let open = OpenReply {
|
||||||
};
|
flags: FLAG_OPEN | FLAG_REPLY | supplied_hmac,
|
||||||
let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap();
|
token: TOKEN,
|
||||||
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC);
|
params: params.clone(),
|
||||||
assert_eq!(
|
};
|
||||||
decode_open_reply(&packet, Some(KEY)),
|
let packet = encode_open_reply(&open, key).unwrap();
|
||||||
Ok(OpenReply {
|
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | auth);
|
||||||
flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC,
|
let offset = if key.is_some() { 20 } else { 4 };
|
||||||
..open_reply
|
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
|
||||||
})
|
assert_eq!(
|
||||||
);
|
decode_open_reply(&packet, key).unwrap(),
|
||||||
|
OpenReply {
|
||||||
let echo_reply = echo_reply(¶ms, FLAG_REPLY);
|
flags: packet[3],
|
||||||
let packet = encode_echo_reply(&echo_reply, ¶ms, Some(KEY)).unwrap();
|
..open
|
||||||
assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC);
|
}
|
||||||
assert_eq!(
|
);
|
||||||
decode_echo_reply(&packet, ¶ms, Some(KEY)),
|
let echo = echo_reply(¶ms, FLAG_REPLY | supplied_hmac);
|
||||||
Ok(EchoReply {
|
let packet = encode_echo_reply(&echo, ¶ms, key).unwrap();
|
||||||
flags: FLAG_REPLY | FLAG_HMAC,
|
assert_eq!(packet[3], FLAG_REPLY | auth);
|
||||||
..echo_reply
|
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
|
||||||
})
|
assert_eq!(&packet[offset + 8..offset + 12], &u32::MAX.to_le_bytes());
|
||||||
);
|
assert_eq!(
|
||||||
|
decode_echo_reply(&packet, ¶ms, key).unwrap(),
|
||||||
|
EchoReply {
|
||||||
|
flags: packet[3],
|
||||||
|
..echo
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -95,6 +103,12 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
|
|||||||
};
|
};
|
||||||
let packet = encode_open_reply(&rejected, None).unwrap();
|
let packet = encode_open_reply(&rejected, None).unwrap();
|
||||||
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
|
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
|
||||||
|
let mut zero_without_close = packet.clone();
|
||||||
|
zero_without_close[3] &= !FLAG_CLOSE;
|
||||||
|
assert_eq!(
|
||||||
|
decode_open_reply(&zero_without_close, None),
|
||||||
|
Err(ProtoError::ZeroToken)
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_open_reply(
|
encode_open_reply(
|
||||||
@@ -119,63 +133,51 @@ fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reply_encoders_supply_their_packet_type_rules() {
|
fn reply_codecs_enforce_packet_type_and_reserved_flag_rules() {
|
||||||
let params = Params::default();
|
let params = Params::default();
|
||||||
assert_eq!(
|
for (flags, expected) in [
|
||||||
encode_open_reply(
|
(FLAG_REPLY, ProtoError::MissingFlag(FLAG_OPEN)),
|
||||||
&OpenReply {
|
(FLAG_OPEN, ProtoError::MissingFlag(FLAG_REPLY)),
|
||||||
flags: FLAG_REPLY,
|
(
|
||||||
token: TOKEN,
|
FLAG_OPEN | FLAG_REPLY | 0x10,
|
||||||
params: params.clone(),
|
ProtoError::ReservedFlags(0x10),
|
||||||
},
|
|
||||||
None,
|
|
||||||
),
|
),
|
||||||
Err(ProtoError::MissingFlag(FLAG_OPEN))
|
] {
|
||||||
);
|
let open = OpenReply {
|
||||||
assert_eq!(
|
flags,
|
||||||
encode_echo_reply(
|
token: TOKEN,
|
||||||
&EchoReply {
|
params: params.clone(),
|
||||||
flags: 0,
|
};
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
assert_eq!(encode_open_reply(&open, None).unwrap_err(), expected);
|
||||||
},
|
let mut packet = encode_open_reply(
|
||||||
¶ms,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Err(ProtoError::MissingFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every packet the reply encoders produce must be rejected by the inbound
|
|
||||||
/// request decoder: a server never answers a datagram carrying `FLAG_REPLY`.
|
|
||||||
#[test]
|
|
||||||
fn encoded_replies_are_never_admitted_as_inbound_requests() {
|
|
||||||
let params = Params::default();
|
|
||||||
|
|
||||||
for reply_flags in [FLAG_OPEN | FLAG_REPLY, FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE] {
|
|
||||||
let packet = encode_open_reply(
|
|
||||||
&OpenReply {
|
&OpenReply {
|
||||||
flags: reply_flags,
|
flags: FLAG_OPEN | FLAG_REPLY,
|
||||||
token: if reply_flags & FLAG_CLOSE == 0 {
|
..open
|
||||||
TOKEN
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
params: params.clone(),
|
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
packet[3] = flags;
|
||||||
decode_request(&packet),
|
assert_eq!(decode_open_reply(&packet, None).unwrap_err(), expected);
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
for (flags, expected) in [
|
||||||
for reply_flags in [FLAG_REPLY, FLAG_REPLY | FLAG_CLOSE] {
|
(0, ProtoError::MissingFlag(FLAG_REPLY)),
|
||||||
let packet = encode_echo_reply(&echo_reply(¶ms, reply_flags), ¶ms, None).unwrap();
|
(
|
||||||
|
FLAG_OPEN | FLAG_REPLY,
|
||||||
|
ProtoError::UnexpectedFlag(FLAG_OPEN),
|
||||||
|
),
|
||||||
|
(FLAG_REPLY | 0x10, ProtoError::ReservedFlags(0x10)),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_request(&packet),
|
encode_echo_reply(&echo_reply(¶ms, flags), ¶ms, None).unwrap_err(),
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
expected
|
||||||
|
);
|
||||||
|
let mut packet =
|
||||||
|
encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None).unwrap();
|
||||||
|
packet[3] = flags;
|
||||||
|
assert_eq!(
|
||||||
|
decode_echo_reply(&packet, ¶ms, None).unwrap_err(),
|
||||||
|
expected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +209,12 @@ fn echo_layout_combinations_round_trip() {
|
|||||||
StampAt::Both,
|
StampAt::Both,
|
||||||
StampAt::Midpoint,
|
StampAt::Midpoint,
|
||||||
] {
|
] {
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
for clock in [
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
] {
|
||||||
for key in [None, Some(KEY)] {
|
for key in [None, Some(KEY)] {
|
||||||
let params = Params {
|
let params = Params {
|
||||||
protocol_version: 1,
|
protocol_version: 1,
|
||||||
@@ -265,44 +272,48 @@ fn echo_reply_requires_exact_negotiated_optional_fields() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
|
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
|
||||||
let params = Params {
|
for stats in [ReceivedStats::None, ReceivedStats::Both] {
|
||||||
length: 20,
|
for key in [None, Some(KEY)] {
|
||||||
..Params::default()
|
let mut params = Params {
|
||||||
};
|
received_stats: stats,
|
||||||
|
..Params::default()
|
||||||
let packet = encode_request(echo_request(¶ms, &[1, 2]), None).unwrap();
|
};
|
||||||
assert_eq!(&packet[16..], &[1, 2, 0, 0]);
|
let offset = PacketLayout::echo(key.is_some(), ¶ms).header_len();
|
||||||
assert_eq!(
|
params.length = (offset + 4) as i64;
|
||||||
encode_request(echo_request(¶ms, &[0; 5]), None),
|
let packet = encode_request(echo_request(¶ms, &[1, 2]), key).unwrap();
|
||||||
Err(ProtoError::PayloadTooLarge {
|
assert_eq!(&packet[offset..], &[1, 2, 0, 0]);
|
||||||
available: 4,
|
assert_eq!(
|
||||||
provided: 5,
|
encode_request(echo_request(¶ms, &[0; 5]), key),
|
||||||
})
|
Err(ProtoError::PayloadTooLarge {
|
||||||
);
|
available: 4,
|
||||||
|
provided: 5
|
||||||
let reply = EchoReply {
|
})
|
||||||
payload: vec![3, 4],
|
);
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
let reply = EchoReply {
|
||||||
};
|
payload: vec![3, 4],
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
|
||||||
assert_eq!(&packet[16..], &[3, 4, 0, 0]);
|
|
||||||
let decoded = decode_echo_reply(&packet, ¶ms, None).unwrap();
|
|
||||||
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
|
|
||||||
assert_eq!(encode_echo_reply(&decoded, ¶ms, None).unwrap(), packet);
|
|
||||||
assert_eq!(
|
|
||||||
encode_echo_reply(
|
|
||||||
&EchoReply {
|
|
||||||
payload: vec![0; 5],
|
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
..echo_reply(¶ms, FLAG_REPLY)
|
||||||
},
|
};
|
||||||
¶ms,
|
let packet = encode_echo_reply(&reply, ¶ms, key).unwrap();
|
||||||
None,
|
assert_eq!(&packet[offset..], &[3, 4, 0, 0]);
|
||||||
),
|
let decoded = decode_echo_reply(&packet, ¶ms, key).unwrap();
|
||||||
Err(ProtoError::PayloadTooLarge {
|
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
|
||||||
available: 4,
|
assert_eq!(encode_echo_reply(&decoded, ¶ms, key).unwrap(), packet);
|
||||||
provided: 5,
|
assert_eq!(
|
||||||
})
|
encode_echo_reply(
|
||||||
);
|
&EchoReply {
|
||||||
|
payload: vec![0; 5],
|
||||||
|
..reply
|
||||||
|
},
|
||||||
|
¶ms,
|
||||||
|
key
|
||||||
|
),
|
||||||
|
Err(ProtoError::PayloadTooLarge {
|
||||||
|
available: 4,
|
||||||
|
provided: 5
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -321,6 +332,14 @@ fn a_negative_negotiated_length_still_encodes_in_both_directions() {
|
|||||||
let request = encode_request(echo_request(¶ms, &[]), None)
|
let request = encode_request(echo_request(¶ms, &[]), None)
|
||||||
.expect("a negative negotiated length must still encode a request");
|
.expect("a negative negotiated length must still encode a request");
|
||||||
assert_eq!(request.len(), 16);
|
assert_eq!(request.len(), 16);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&request).unwrap().kind,
|
||||||
|
DecodedRequestKind::Echo {
|
||||||
|
token: TOKEN,
|
||||||
|
sequence: 17,
|
||||||
|
tail: &[]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let reply = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None)
|
let reply = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None)
|
||||||
.expect("a negative negotiated length must still encode a reply");
|
.expect("a negative negotiated length must still encode a reply");
|
||||||
@@ -505,12 +524,14 @@ fn midpoint_dual_field_reply_authenticates_over_full_packet() {
|
|||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(KEY)).unwrap();
|
let reply = decode_echo_reply(&packet, ¶ms, Some(KEY)).unwrap();
|
||||||
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
|
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
|
||||||
|
|
||||||
let mut corrupted = packet;
|
for offset in [HMAC_OFFSET, PacketLayout::echo(true, ¶ms).header_len()] {
|
||||||
corrupted[HMAC_OFFSET] ^= 0xFF;
|
let mut corrupted = packet.clone();
|
||||||
assert_eq!(
|
corrupted[offset] ^= 0xFF;
|
||||||
decode_echo_reply(&corrupted, ¶ms, Some(KEY)),
|
assert_eq!(
|
||||||
Err(ProtoError::BadHmac)
|
decode_echo_reply(&corrupted, ¶ms, Some(KEY)),
|
||||||
);
|
Err(ProtoError::BadHmac)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -539,61 +560,92 @@ fn only_the_exact_compat_length_is_accepted_as_an_extension() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn midpoint_both_clock_reply_has_no_additional_extension() {
|
fn other_layouts_have_no_longer_form_exception() {
|
||||||
let params = midpoint_params(Clock::Both, 0);
|
for (stamp_at, clock) in [
|
||||||
let reply = echo_reply(¶ms, FLAG_REPLY);
|
(StampAt::Midpoint, Clock::Both),
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
(StampAt::Receive, Clock::Wall),
|
||||||
|
] {
|
||||||
let mut too_long = packet.clone();
|
let params = Params {
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
stamp_at,
|
||||||
assert_eq!(
|
clock,
|
||||||
decode_echo_reply(&too_long, ¶ms, None),
|
..Params::default()
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
};
|
||||||
expected: packet.len(),
|
let packet = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None).unwrap();
|
||||||
actual: too_long.len(),
|
let mut too_long = packet.clone();
|
||||||
})
|
too_long.extend_from_slice(&[0; 8]);
|
||||||
);
|
assert_eq!(
|
||||||
|
decode_echo_reply(&too_long, ¶ms, None),
|
||||||
|
Err(ProtoError::PacketLengthMismatch {
|
||||||
|
expected: packet.len(),
|
||||||
|
actual: too_long.len()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_midpoint_stamp_at_has_no_longer_form_exception() {
|
fn reply_decoders_enforce_hmac_presence_and_authenticate_the_body() {
|
||||||
let params = Params {
|
let params = params();
|
||||||
received_stats: ReceivedStats::None,
|
for key in [None, Some(KEY)] {
|
||||||
stamp_at: StampAt::Receive,
|
let open = encode_open_reply(
|
||||||
clock: Clock::Wall,
|
&OpenReply {
|
||||||
..Params::default()
|
flags: FLAG_OPEN | FLAG_REPLY,
|
||||||
};
|
token: TOKEN,
|
||||||
let reply = echo_reply(¶ms, FLAG_REPLY);
|
params: params.clone(),
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
},
|
||||||
|
key,
|
||||||
let mut too_long = packet.clone();
|
)
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
.unwrap();
|
||||||
assert_eq!(
|
let echo = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, key).unwrap();
|
||||||
decode_echo_reply(&too_long, ¶ms, None),
|
let mismatched_key = if key.is_some() { None } else { Some(KEY) };
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
assert_eq!(
|
||||||
expected: packet.len(),
|
decode_open_reply(&open, mismatched_key),
|
||||||
actual: too_long.len(),
|
Err(ProtoError::HmacPresenceMismatch)
|
||||||
})
|
);
|
||||||
);
|
assert_eq!(
|
||||||
}
|
decode_echo_reply(&echo, ¶ms, mismatched_key),
|
||||||
|
Err(ProtoError::HmacPresenceMismatch)
|
||||||
/// The midpoint compatibility length is a *reply* concept. An inbound ECHO
|
);
|
||||||
/// request of that length is just a longer request, which a receiver accepts
|
if let Some(key) = key {
|
||||||
/// without applying any negotiated length rule.
|
let mut bad_open = open;
|
||||||
#[test]
|
let mut bad_echo = echo;
|
||||||
fn echo_request_decoding_has_no_compat_length_rule() {
|
*bad_open.last_mut().unwrap() ^= 1;
|
||||||
let params = midpoint_params(Clock::Wall, 0);
|
*bad_echo.last_mut().unwrap() ^= 1;
|
||||||
let packet = encode_request(echo_request(¶ms, &[]), None).unwrap();
|
assert_eq!(
|
||||||
|
decode_open_reply(&bad_open, Some(key)),
|
||||||
let mut too_long = packet.clone();
|
Err(ProtoError::BadHmac)
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
);
|
||||||
let request = decode_request(&too_long).unwrap();
|
assert_eq!(
|
||||||
assert_eq!(
|
decode_echo_reply(&bad_echo, ¶ms, Some(key)),
|
||||||
request.kind,
|
Err(ProtoError::BadHmac)
|
||||||
DecodedRequestKind::Echo {
|
);
|
||||||
token: TOKEN,
|
|
||||||
sequence: 17,
|
|
||||||
tail: &too_long[16..],
|
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_reply_length_errors_precede_hmac_verification() {
|
||||||
|
for key in [None, Some(KEY)] {
|
||||||
|
let mut params = Params::default();
|
||||||
|
let header = PacketLayout::echo(key.is_some(), ¶ms).header_len();
|
||||||
|
params.length = (header + 4) as i64;
|
||||||
|
let packet = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, key).unwrap();
|
||||||
|
for actual in [header - 1, header, packet.len() - 1, packet.len() + 1] {
|
||||||
|
let mut malformed = packet.clone();
|
||||||
|
malformed.resize(actual, 0);
|
||||||
|
// Truncating or extending the signed packet also invalidates its MAC.
|
||||||
|
let expected = if actual < header {
|
||||||
|
ProtoError::PacketTooShort {
|
||||||
|
needed: header,
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProtoError::PacketLengthMismatch {
|
||||||
|
expected: packet.len(),
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(decode_echo_reply(&malformed, ¶ms, key), Err(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -47,8 +47,6 @@ fn token_and_sequence() -> Vec<u8> {
|
|||||||
body
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- A. Classification ----------
|
|
||||||
|
|
||||||
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
|
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
DecodedRequestKind::Open { no_test: false, .. } => "open",
|
DecodedRequestKind::Open { no_test: false, .. } => "open",
|
||||||
@@ -71,6 +69,34 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
|
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
for no_test in [false, true] {
|
||||||
|
for key in [None, Some(KEY)] {
|
||||||
|
let params = Params::default();
|
||||||
|
let packet = encode_request(
|
||||||
|
RequestToEncode::Open {
|
||||||
|
params: ¶ms,
|
||||||
|
no_test,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected_flags = FLAG_OPEN
|
||||||
|
| if no_test { FLAG_CLOSE } else { 0 }
|
||||||
|
| if key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
assert_eq!(packet[3], expected_flags);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&packet).unwrap().kind,
|
||||||
|
DecodedRequestKind::Open {
|
||||||
|
no_test,
|
||||||
|
params: &[]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if let Some(key) = key {
|
||||||
|
verify_packet_hmac(key, &packet).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (flags, hmac_present, expected) in cases {
|
for (flags, hmac_present, expected) in cases {
|
||||||
// Long enough for any kind, so only the flags decide classification.
|
// Long enough for any kind, so only the flags decide classification.
|
||||||
let packet = raw(flags, &token_and_sequence(), 64);
|
let packet = raw(flags, &token_and_sequence(), 64);
|
||||||
@@ -82,6 +108,10 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
request.hmac_present, hmac_present,
|
request.hmac_present, hmac_present,
|
||||||
"flags 0x{flags:02x} HMAC presence"
|
"flags 0x{flags:02x} HMAC presence"
|
||||||
);
|
);
|
||||||
|
if let DecodedRequestKind::Open { params, .. } = request.kind {
|
||||||
|
let offset = if hmac_present { 20 } else { 4 };
|
||||||
|
assert_eq!(params, &packet[offset..]);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(&request.kind),
|
classify(&request.kind),
|
||||||
expected,
|
expected,
|
||||||
@@ -90,8 +120,6 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- B. Invalid header / flags ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structurally_invalid_headers_are_rejected() {
|
fn structurally_invalid_headers_are_rejected() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -135,8 +163,6 @@ fn any_reply_flagged_datagram_is_rejected_as_a_request() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- C. Structural minimum lengths ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structural_minimum_lengths_are_enforced_per_kind() {
|
fn structural_minimum_lengths_are_enforced_per_kind() {
|
||||||
// (flags, minimum accepted length)
|
// (flags, minimum accepted length)
|
||||||
@@ -172,8 +198,6 @@ fn structural_minimum_lengths_are_enforced_per_kind() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- D. Field offsets ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
||||||
for hmac in [false, true] {
|
for hmac in [false, true] {
|
||||||
@@ -211,69 +235,25 @@ fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- E. Open parameter slice ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
|
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
|
||||||
let encoded = params().encode();
|
// Structural parsing accepts even malformed Params; semantic errors belong
|
||||||
|
// to Params::decode and are covered in params.rs.
|
||||||
for hmac in [false, true] {
|
for body in [params().encode(), Vec::new(), vec![1, 0x80]] {
|
||||||
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
|
for hmac in [false, true] {
|
||||||
let expected_offset = if hmac { 4 + HMAC_SIZE } else { 4 };
|
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
|
||||||
let packet = raw(flags, &encoded, expected_offset + encoded.len());
|
let offset = if hmac { 20 } else { 4 };
|
||||||
|
let packet = raw(flags, &body, offset + body.len());
|
||||||
match decode_request(&packet).unwrap().kind {
|
match decode_request(&packet).unwrap().kind {
|
||||||
DecodedRequestKind::Open { params, .. } => {
|
DecodedRequestKind::Open { params, .. } => {
|
||||||
assert_eq!(params, &packet[expected_offset..]);
|
assert_eq!(params, &packet[offset..]);
|
||||||
assert_eq!(Params::decode(params).unwrap(), self::params());
|
}
|
||||||
|
other => panic!("expected an open request, got {other:?}"),
|
||||||
}
|
}
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_and_malformed_open_params_decode_structurally() {
|
|
||||||
let empty = header(FLAG_OPEN);
|
|
||||||
match decode_request(&empty).unwrap().kind {
|
|
||||||
DecodedRequestKind::Open { params, .. } => assert!(params.is_empty()),
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// A truncated varint: structurally fine, semantically not. A receiver must
|
|
||||||
// be able to authenticate before spending effort on this.
|
|
||||||
let malformed = raw(FLAG_OPEN, &[1, 0x80], 6);
|
|
||||||
match decode_request(&malformed).unwrap().kind {
|
|
||||||
DecodedRequestKind::Open { params, .. } => {
|
|
||||||
assert_eq!(params, &[1, 0x80]);
|
|
||||||
assert_eq!(Params::decode(params), Err(ProtoError::TruncatedVarint));
|
|
||||||
}
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- F. Open precedence ----------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_precedence_beats_close_and_an_echo_shaped_body() {
|
|
||||||
// An OPEN|CLOSE datagram whose body looks exactly like a token and
|
|
||||||
// sequence number is still a no-test open, and those bytes are parameters.
|
|
||||||
let packet = raw(FLAG_OPEN | FLAG_CLOSE, &token_and_sequence(), 16);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_request(&packet).unwrap(),
|
|
||||||
DecodedRequest {
|
|
||||||
hmac_present: false,
|
|
||||||
kind: DecodedRequestKind::Open {
|
|
||||||
no_test: true,
|
|
||||||
params: &packet[4..],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- G. Trailing data ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
||||||
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
|
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
|
||||||
@@ -303,8 +283,6 @@ fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- H. Zero token ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_zero_token_is_structurally_valid() {
|
fn a_zero_token_is_structurally_valid() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -323,8 +301,6 @@ fn a_zero_token_is_structurally_valid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- HMAC presence is not authentication ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hmac_presence_is_reported_independently_of_mac_validity() {
|
fn hmac_presence_is_reported_independently_of_mac_validity() {
|
||||||
// A structurally complete authenticated echo whose MAC is garbage.
|
// A structurally complete authenticated echo whose MAC is garbage.
|
||||||
@@ -378,106 +354,3 @@ fn packet_verification_requires_a_present_and_complete_hmac_field() {
|
|||||||
Err(ProtoError::BadMagic)
|
Err(ProtoError::BadMagic)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Encode -> decode cross-checks ----------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_requests_decode_back_to_their_sender_side_identity() {
|
|
||||||
for key in [None, Some(KEY)] {
|
|
||||||
for no_test in [false, true] {
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Open {
|
|
||||||
params: ¶ms(),
|
|
||||||
no_test,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
match request.kind {
|
|
||||||
DecodedRequestKind::Open {
|
|
||||||
no_test: decoded,
|
|
||||||
params: encoded,
|
|
||||||
} => {
|
|
||||||
assert_eq!(decoded, no_test);
|
|
||||||
assert_eq!(Params::decode(encoded).unwrap(), params());
|
|
||||||
}
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
|
||||||
if let Some(key) = key {
|
|
||||||
verify_packet_hmac(key, &packet).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = encode_request(RequestToEncode::Close { token: TOKEN }, key).unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
assert_eq!(request.kind, DecodedRequestKind::Close { token: TOKEN });
|
|
||||||
|
|
||||||
// The ECHO tail is deliberately not compared against the logical
|
|
||||||
// payload: the sender placed it at a negotiated offset the receiver
|
|
||||||
// cannot recover without `Params`.
|
|
||||||
let echo_params = Params {
|
|
||||||
length: 96,
|
|
||||||
..params()
|
|
||||||
};
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
params: &echo_params,
|
|
||||||
payload: &[1, 2, 3, 4],
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
match request.kind {
|
|
||||||
DecodedRequestKind::Echo {
|
|
||||||
token, sequence, ..
|
|
||||||
} => {
|
|
||||||
assert_eq!(token, TOKEN);
|
|
||||||
assert_eq!(sequence, SEQUENCE);
|
|
||||||
}
|
|
||||||
other => panic!("expected an echo request, got {other:?}"),
|
|
||||||
}
|
|
||||||
if let Some(key) = key {
|
|
||||||
verify_packet_hmac(key, &packet).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_encoding_floors_a_negative_negotiated_length_at_the_field_block() {
|
|
||||||
// A negative negotiated length is accepted during open and echoed back
|
|
||||||
// unchanged, so a real session can carry one. It must therefore stay
|
|
||||||
// encodable: it asks for no space beyond the mandatory fields.
|
|
||||||
let params = Params {
|
|
||||||
length: -1,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
params: ¶ms,
|
|
||||||
payload: &[],
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("a negative negotiated length must still encode");
|
|
||||||
|
|
||||||
// header (4) + token (8) + sequence (4).
|
|
||||||
assert_eq!(packet.len(), 16);
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
request.kind,
|
|
||||||
DecodedRequestKind::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
tail: &[],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use irtt_proto::{varint::*, ProtoError};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_values_round_trip() {
|
||||||
|
for value in [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
127,
|
||||||
|
128,
|
||||||
|
255,
|
||||||
|
16_384,
|
||||||
|
u32::MAX as u64,
|
||||||
|
i64::MAX as u64,
|
||||||
|
u64::MAX,
|
||||||
|
] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_uvarint(value, &mut encoded);
|
||||||
|
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
i64::MIN,
|
||||||
|
-9_223_372_036,
|
||||||
|
-2,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1_000_000_000,
|
||||||
|
3_000_000_000,
|
||||||
|
i64::MAX,
|
||||||
|
] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_varint(value, &mut encoded);
|
||||||
|
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_byte_examples() {
|
||||||
|
let cases: &[(i64, &[u8])] = &[
|
||||||
|
(1, &[0x02]),
|
||||||
|
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
|
||||||
|
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
|
||||||
|
(1472, &[0x80, 0x17]),
|
||||||
|
(3, &[0x06]),
|
||||||
|
(184, &[0xf0, 0x02]),
|
||||||
|
];
|
||||||
|
for (value, expected) in cases {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_varint(*value, &mut encoded);
|
||||||
|
assert_eq!(&encoded, expected);
|
||||||
|
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
|
||||||
|
}
|
||||||
|
for tag in [9, 24] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_uvarint(tag, &mut encoded);
|
||||||
|
assert_eq!(encoded, [tag as u8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_and_overflowing_varints_are_rejected() {
|
||||||
|
// Incomplete input differs from a tenth byte whose bits cannot fit u64,
|
||||||
|
// or an eleventh byte after ten continuation bytes.
|
||||||
|
for (bytes, expected) in [
|
||||||
|
(vec![], ProtoError::TruncatedVarint),
|
||||||
|
(vec![0x80], ProtoError::TruncatedVarint),
|
||||||
|
(vec![0x80; 10], ProtoError::TruncatedVarint),
|
||||||
|
(
|
||||||
|
[vec![0x80; 9], vec![2]].concat(),
|
||||||
|
ProtoError::VarintOverflow,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[vec![0x80; 10], vec![0]].concat(),
|
||||||
|
ProtoError::VarintOverflow,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(decode_uvarint(&bytes), Err(expected), "{bytes:02x?}");
|
||||||
|
assert_eq!(
|
||||||
|
decode_varint(&bytes),
|
||||||
|
decode_uvarint(&bytes).map(|(v, n)| (zigzag_decode(v), n))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
prop_assert_eq!(decode_uvarint(&encoded).unwrap(), (value, encoded.len()));
|
||||||
|
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
prop_assert_eq!(decode_varint(&encoded).unwrap(), (value, encoded.len()));
|
||||||
|
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-server"
|
name = "irtt-server"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
description = "IRTT-compatible Rust server library"
|
description = "IRTT-compatible Rust server library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "server"]
|
keywords = ["irtt", "networking", "latency", "server"]
|
||||||
categories = ["network-programming"]
|
categories = ["network-programming"]
|
||||||
@@ -16,7 +15,7 @@ irtt-proto.workspace = true
|
|||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
socket2 = { version = "0.6.5", features = ["all"] }
|
socket2 = { version = "0.6.5", features = ["all"] }
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||||
|
|
||||||
# Wildcard listeners recover each request's local destination address and send
|
# Wildcard listeners recover each request's local destination address and send
|
||||||
# the reply from it. Only these targets have a safe destination-metadata and
|
# the reply from it. Only these targets have a safe destination-metadata and
|
||||||
|
|||||||
@@ -108,6 +108,9 @@
|
|||||||
//! payload bytes never reach a reply under any mode.
|
//! payload bytes never reach a reply under any mode.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
/// Cargo package version of this compiled library.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
mod clock;
|
mod clock;
|
||||||
mod config;
|
mod config;
|
||||||
mod core;
|
mod core;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
description = "Statistics aggregation for irtt-client events"
|
description = "Statistics aggregation for irtt-client events"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "statistics"]
|
keywords = ["irtt", "networking", "latency", "statistics"]
|
||||||
categories = ["network-programming"]
|
categories = ["network-programming"]
|
||||||
@@ -19,4 +18,4 @@ irtt-client.workspace = true
|
|||||||
name = "finite_vs_continuous"
|
name = "finite_vs_continuous"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1.11.0"
|
proptest = "1.12.0"
|
||||||
@@ -16,10 +16,16 @@ re-export. Event interpretation, IPDV pairing, loss, and snapshot reconstruction
|
|||||||
remain here.
|
remain here.
|
||||||
|
|
||||||
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
|
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
|
||||||
scoped to a single target. A managed session covering multiple targets needs
|
scoped to a single target session. A managed session covering multiple targets
|
||||||
one collector per target — as the `irtt-rs` CLI does — since a target's
|
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
|
sequence numbers start over at zero and feeding two targets into one
|
||||||
collector would pair up unrelated probes.
|
collector would pair up unrelated probes. Reset or replace the collector when a
|
||||||
|
target reconnects, since the new session restarts its sequence numbers.
|
||||||
|
|
||||||
|
Version 0.6 consumes `irtt-client` 0.7 events. Applications using both crates
|
||||||
|
must upgrade them together to avoid incompatible `ClientEvent` types.
|
||||||
|
`IpdvPairUpdate` exposes signed nanosecond changes alongside the existing
|
||||||
|
absolute `Duration` values; aggregate IPDV statistics remain absolute magnitudes.
|
||||||
|
|
||||||
## Retention modes
|
## Retention modes
|
||||||
|
|
||||||
|
|||||||
@@ -175,29 +175,31 @@ impl CoreStats {
|
|||||||
let mut updates = Vec::new();
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
for pair in self.ipdv_tracker.insert(sample) {
|
for pair in self.ipdv_tracker.insert(sample) {
|
||||||
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(pair.rtt_ipdv_ns) else {
|
let rtt_magnitude_ns = pair.rtt_ipdv_ns.saturating_abs();
|
||||||
|
let send_magnitude_ns = pair.send_ipdv_ns.map(i128::saturating_abs);
|
||||||
|
let receive_magnitude_ns = pair.receive_ipdv_ns.map(i128::saturating_abs);
|
||||||
|
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(rtt_magnitude_ns) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let send_ipdv = pair
|
let send_ipdv = send_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
|
||||||
.send_ipdv_ns
|
let receive_ipdv = receive_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
|
||||||
.and_then(duration_from_non_negative_i128_ns);
|
|
||||||
let receive_ipdv = pair
|
|
||||||
.receive_ipdv_ns
|
|
||||||
.and_then(duration_from_non_negative_i128_ns);
|
|
||||||
|
|
||||||
self.ipdv_round_trip.push_ns(pair.rtt_ipdv_ns);
|
self.ipdv_round_trip.push_ns(rtt_magnitude_ns);
|
||||||
|
|
||||||
if let Some(value) = pair.send_ipdv_ns {
|
if let Some(value) = send_magnitude_ns {
|
||||||
self.ipdv_send.push_ns(value);
|
self.ipdv_send.push_ns(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = pair.receive_ipdv_ns {
|
if let Some(value) = receive_magnitude_ns {
|
||||||
self.ipdv_receive.push_ns(value);
|
self.ipdv_receive.push_ns(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push(IpdvPairUpdate {
|
updates.push(IpdvPairUpdate {
|
||||||
previous_seq: pair.previous_seq,
|
previous_seq: pair.previous_seq,
|
||||||
current_seq: pair.current_seq,
|
current_seq: pair.current_seq,
|
||||||
|
rtt_ipdv_ns: pair.rtt_ipdv_ns,
|
||||||
|
send_ipdv_ns: pair.send_ipdv_ns,
|
||||||
|
receive_ipdv_ns: pair.receive_ipdv_ns,
|
||||||
rtt_ipdv,
|
rtt_ipdv,
|
||||||
send_ipdv,
|
send_ipdv,
|
||||||
receive_ipdv,
|
receive_ipdv,
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ impl IpdvTracker {
|
|||||||
Some(CompletedIpdvPair {
|
Some(CompletedIpdvPair {
|
||||||
previous_seq,
|
previous_seq,
|
||||||
current_seq,
|
current_seq,
|
||||||
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns),
|
rtt_ipdv_ns: current.rtt_primary_ns - previous.rtt_primary_ns,
|
||||||
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns),
|
send_ipdv_ns: send_ipdv_ns(previous, current),
|
||||||
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns),
|
receive_ipdv_ns: receive_ipdv_ns(previous, current),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,10 +161,6 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
|||||||
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
|
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn abs_i128_ns(value: i128) -> i128 {
|
|
||||||
value.saturating_abs()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![warn(rustdoc::missing_crate_level_docs)]
|
#![warn(rustdoc::missing_crate_level_docs)]
|
||||||
|
|
||||||
|
/// Cargo package version of this compiled library.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use irtt_client::ClientEvent;
|
use irtt_client::ClientEvent;
|
||||||
@@ -520,6 +523,12 @@ pub struct IpdvPairUpdate {
|
|||||||
pub previous_seq: u32,
|
pub previous_seq: u32,
|
||||||
/// Sequence number of the later reply in the adjacent pair.
|
/// Sequence number of the later reply in the adjacent pair.
|
||||||
pub current_seq: u32,
|
pub current_seq: u32,
|
||||||
|
/// Signed round-trip delay change (current minus previous) in nanoseconds.
|
||||||
|
pub rtt_ipdv_ns: i128,
|
||||||
|
/// Signed send-side delay change in nanoseconds, when available for both replies.
|
||||||
|
pub send_ipdv_ns: Option<i128>,
|
||||||
|
/// Signed receive-side delay change in nanoseconds, when available for both replies.
|
||||||
|
pub receive_ipdv_ns: Option<i128>,
|
||||||
/// Absolute round-trip IPDV between the adjacent replies.
|
/// Absolute round-trip IPDV between the adjacent replies.
|
||||||
pub rtt_ipdv: Duration,
|
pub rtt_ipdv: Duration,
|
||||||
/// Absolute send-side IPDV when send one-way delay is available for both replies.
|
/// Absolute send-side IPDV when send one-way delay is available for both replies.
|
||||||
|
|||||||
@@ -191,10 +191,15 @@ impl ReplySample {
|
|||||||
client_receive_mono: received_at.mono,
|
client_receive_mono: received_at.mono,
|
||||||
client_send_wall_ns: system_time_ns(sent_at.wall),
|
client_send_wall_ns: system_time_ns(sent_at.wall),
|
||||||
client_receive_wall_ns: system_time_ns(received_at.wall),
|
client_receive_wall_ns: system_time_ns(received_at.wall),
|
||||||
server_receive_mono_ns: server_timing.and_then(|timing| timing.receive_mono_ns),
|
// Midpoint timestamps serve as both directional endpoints.
|
||||||
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns),
|
server_receive_mono_ns: server_timing
|
||||||
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns),
|
.and_then(|timing| timing.receive_mono_ns.or(timing.midpoint_mono_ns)),
|
||||||
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns),
|
server_send_mono_ns: server_timing
|
||||||
|
.and_then(|timing| timing.send_mono_ns.or(timing.midpoint_mono_ns)),
|
||||||
|
server_receive_wall_ns: server_timing
|
||||||
|
.and_then(|timing| timing.receive_wall_ns.or(timing.midpoint_wall_ns)),
|
||||||
|
server_send_wall_ns: server_timing
|
||||||
|
.and_then(|timing| timing.send_wall_ns.or(timing.midpoint_wall_ns)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
# Interop Comparison
|
# Interop comparison
|
||||||
|
|
||||||
|
## Feature comparison
|
||||||
|
|
||||||
|
This compares `irtt-rs` with the original [heistp/irtt](https://github.com/heistp/irtt)
|
||||||
|
release [v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
|
||||||
|
The aim is for the two implementations to interoperate, though the interop
|
||||||
|
tests and verified protocol baseline still use upstream v0.9.1. Both
|
||||||
|
upstream releases use wire protocol version 1, but v0.9.2 hasn't been run
|
||||||
|
through this project's interop harness. See [`docs/protocol/`](protocol/)
|
||||||
|
for the verified protocol baseline.
|
||||||
|
|
||||||
|
| Capability | irtt-rs | Upstream IRTT (v0.9.2) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wire protocol | Version 1, verified against upstream v0.9.1 | Version 1, unchanged from v0.9.1 |
|
||||||
|
| Finite probing | Default duration of 10s | Default duration of 1m |
|
||||||
|
| Continuous probing | `--continuous` or `--duration 0`, with optional per-target reconnection | Streaming mode (`-s`, added in v0.9.2) |
|
||||||
|
| Statistics in continuous mode | Optional count/min/max/mean/variance per session, with bounded IPDV tracking and no exact medians. Disable with `--no-stats` or omit the `stats` feature | No statistics; raw/event output only to avoid unbounded memory |
|
||||||
|
| Multiple targets | Labels and staggered/burst pacing in one invocation | One remote address per invocation |
|
||||||
|
| Terminal UI | Live graph and dashboard through `irtt-tui` | None |
|
||||||
|
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and fixed-field raw output; no CSV/TSV or column selection |
|
||||||
|
| Replay saved results | Not implemented | `report` subcommand for saved JSON results |
|
||||||
|
| Benchmarking and clock diagnostics | Not implemented | Benchmarking and timer/clock-diagnostic subcommands |
|
||||||
|
| Client payload and timing controls | No client-side payload fill or don't-fragment/wait-strategy/timer-algorithm controls | Client-side payload fill and controls for don't-fragment, wait strategy, and timer algorithm |
|
||||||
|
| Kernel timestamps | Best-effort preference on Linux for its own send/receive endpoint | Not implemented |
|
||||||
|
| Server listeners | Several listeners in one process, supervised by `ServerSet` | Multiple requested addresses through `ListenAndServe` |
|
||||||
|
| Server wildcard reply-source selection | Default on Linux/macOS/FreeBSD; other platforms refuse wildcard binds | Opt-in through `--set-src-ip`, with a documented per-packet allocation cost |
|
||||||
|
| Server default bind | Wildcard addresses for both families, subject to the platform restriction above | Wildcard addresses for both families; no documented platform restriction |
|
||||||
|
| Server payload fill policy | Honors the session's negotiated request, with a fixed fallback pattern; no options to restrict or override it | Options to restrict or override client fill requests |
|
||||||
|
| Syslog logging | Not implemented | Supported |
|
||||||
|
| Bundled service files | None | systemd and OpenRC |
|
||||||
|
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One Go package exposing both roles |
|
||||||
|
| Prebuilt release binaries | Published for six target triples | None published; install through `go install` or build manually |
|
||||||
|
|
||||||
|
## Black-box testing
|
||||||
|
|
||||||
`scripts/compare_irtt_clients.py` is a black-box comparison harness for running
|
`scripts/compare_irtt_clients.py` is a black-box comparison harness for running
|
||||||
upstream `irtt` and `irtt-rs` clients concurrently against the same server. It
|
upstream `irtt` and `irtt-rs` clients concurrently against the same server. It
|
||||||
@@ -8,7 +42,7 @@ summaries, and comparison JSON under `target/interop-comparisons` by default.
|
|||||||
The harness does not inspect upstream source or tests. It only invokes the
|
The harness does not inspect upstream source or tests. It only invokes the
|
||||||
upstream executable.
|
upstream executable.
|
||||||
|
|
||||||
## Remote or Existing Local Server
|
### Remote or Existing Local Server
|
||||||
|
|
||||||
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
|
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
|
||||||
|
|
||||||
@@ -36,7 +70,7 @@ python3 scripts/compare_irtt_clients.py \
|
|||||||
Remote comparisons are useful for smoke coverage, but Internet paths are noisy
|
Remote comparisons are useful for smoke coverage, but Internet paths are noisy
|
||||||
and can make RTT/loss differences hard to interpret.
|
and can make RTT/loss differences hard to interpret.
|
||||||
|
|
||||||
## Linux Netem Interop
|
### Linux Netem Interop
|
||||||
|
|
||||||
`scripts/run_netem_interop_ci.py` provides deterministic Linux CI coverage using
|
`scripts/run_netem_interop_ci.py` provides deterministic Linux CI coverage using
|
||||||
network namespaces, a veth pair, and `tc netem`. It is intended for GitHub
|
network namespaces, a veth pair, and `tc netem`. It is intended for GitHub
|
||||||
@@ -69,7 +103,7 @@ Netem qdiscs are applied on veth egress:
|
|||||||
Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
|
Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
|
||||||
a `finally` path.
|
a `finally` path.
|
||||||
|
|
||||||
### Scenarios
|
#### Scenarios
|
||||||
|
|
||||||
The CI harness implements:
|
The CI harness implements:
|
||||||
|
|
||||||
@@ -86,7 +120,7 @@ both clients run in the same client namespace against the same server namespace,
|
|||||||
relative one-way shape is still useful for diagnostics, but the CI pass/fail
|
relative one-way shape is still useful for diagnostics, but the CI pass/fail
|
||||||
policy is based on RTT and packet behavior.
|
policy is based on RTT and packet behavior.
|
||||||
|
|
||||||
### Pass/Fail Policy
|
#### Pass/Fail Policy
|
||||||
|
|
||||||
Deterministic scenarios fail when:
|
Deterministic scenarios fail when:
|
||||||
|
|
||||||
@@ -113,7 +147,7 @@ python3 scripts/run_netem_interop_ci.py \
|
|||||||
--loss-rate-warning-pct 12
|
--loss-rate-warning-pct 12
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Linux Usage
|
#### Manual Linux Usage
|
||||||
|
|
||||||
On Linux:
|
On Linux:
|
||||||
|
|
||||||
|
|||||||
+330
-139
@@ -1,29 +1,31 @@
|
|||||||
# irtt-client
|
# irtt-client
|
||||||
|
|
||||||
## NAME
|
## Name
|
||||||
|
|
||||||
irtt-client - IRTT-compatible stream client
|
irtt-client - IRTT-compatible stream client
|
||||||
|
|
||||||
## SYNOPSIS
|
## Synopsis
|
||||||
|
|
||||||
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
|
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
|
||||||
|
|
||||||
`irtt-client` `--list-columns`
|
`irtt-client` `--list-columns`
|
||||||
|
|
||||||
## DESCRIPTION
|
## Description
|
||||||
|
|
||||||
`irtt-client` probes one or more IRTT-compatible servers over UDP and prints
|
Use `irtt-client` to probe one or more IRTT-compatible servers over UDP.
|
||||||
per-probe measurement events as they arrive, plus a final summary for
|
It prints measurement events as they arrive, and a final summary when the
|
||||||
eligible runs.
|
run qualifies for one.
|
||||||
|
|
||||||
## TARGETS
|
## Targets
|
||||||
|
|
||||||
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
|
Write targets as `[LABEL=]TARGET[@hmac=KEY]`. You can add a `LABEL=` prefix
|
||||||
`LABEL=` prefix assigns the logical target name used in output; without it,
|
to choose the name shown in output. If you leave it out, the client uses
|
||||||
the target string itself is used as the label. Labeled and unlabeled targets
|
the target string as its label. You can mix labeled and unlabeled targets
|
||||||
can be freely mixed in one argument list. Prefer explicit labels for
|
in the same command.
|
||||||
long-running dynamic target sets: the label is the stable target identity
|
|
||||||
across replacements.
|
If you're changing targets during a long run, give them explicit labels.
|
||||||
|
The label identifies the same logical target when you replace its
|
||||||
|
configuration.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client host-a:2112
|
irtt-client host-a:2112
|
||||||
@@ -37,49 +39,131 @@ overrides it for that target, and `@hmac=` explicitly disables HMAC for that
|
|||||||
target. The modifier uses `@`, not a shell command separator; quote an argument
|
target. The modifier uses `@`, not a shell command separator; quote an argument
|
||||||
if its key needs shell quoting.
|
if its key needs shell quoting.
|
||||||
|
|
||||||
|
Both the global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text
|
||||||
|
keys or hexadecimal keys prefixed with `0x`. For example, `secret` and
|
||||||
|
`0x736563726574` are equivalent.
|
||||||
|
|
||||||
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
||||||
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||||
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||||
label or address when it would otherwise begin `@hmac=`; escaping the first
|
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
|
`=` lets an unlabeled target or a label contain that character. An `=` in
|
||||||
an HMAC value, including Base64 padding, does not need escaping. Positional
|
an HMAC value does not need escaping. Positional target arguments may
|
||||||
target arguments may contain a raw comma; stdin target sets have the additional
|
contain a raw comma; stdin target sets have the additional `\,` escape
|
||||||
`\,` escape described below.
|
described below.
|
||||||
|
|
||||||
Shells process unquoted backslashes and characters such as semicolons before
|
Shells process unquoted backslashes and characters such as semicolons before
|
||||||
the client sees them. Single-quote a target when it contains parser escapes,
|
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
|
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
|
||||||
backslashes reach `irtt-client` unchanged.
|
backslashes reach `irtt-client` unchanged.
|
||||||
|
|
||||||
Multiple targets are probed concurrently. By default, a multi-target session
|
The client probes multiple targets concurrently. By default it staggers
|
||||||
uses **staggered** pacing, spacing active targets across the probe interval;
|
them across the probe interval. If you want to send one probe to every
|
||||||
pass `--pacing burst` to send one probe to every active target back-to-back
|
active target back-to-back once per interval, use `--pacing burst`.
|
||||||
once per interval instead. Final per-target summaries print in the order
|
Final summaries follow the command-line target order, regardless of which
|
||||||
targets were supplied on the command line, not the order in which they
|
target finished first or how their labels sort.
|
||||||
finish or their labels sort alphabetically.
|
|
||||||
|
|
||||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
## Address family
|
||||||
|
|
||||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
By default, each target uses the first address returned by the system resolver,
|
||||||
Positional targets remain an initial desired set, and may be omitted to
|
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
|
||||||
start empty:
|
`-6` / `--ipv6` to select IPv6 only. Hostnames are resolved when each session
|
||||||
|
connects; these flags do not perform an extra discovery lookup. An explicit
|
||||||
|
IP literal from the other family is rejected during preparation.
|
||||||
|
|
||||||
|
Use `--dual-stack` to compare IPv4 and IPv6 paths as separate measurements:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client -6 host.example
|
||||||
|
irtt-client --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
For each hostname, the client discovers which address families the system
|
||||||
|
resolver currently returns. IPv4 results create `cov/v4`, IPv6 results create
|
||||||
|
`cov/v6`, and both create both targets, in that order. Without an alias,
|
||||||
|
`host.example` becomes `host.example/v4` and/or `host.example/v6`. Multiple
|
||||||
|
addresses within one family still produce only one target. Explicit IP literals
|
||||||
|
keep their original label and remain single targets.
|
||||||
|
|
||||||
|
Each target has its own session, statistics, and failures. Its original hostname
|
||||||
|
is retained: every new session resolves it again and selects the first address
|
||||||
|
matching its family. Families are discovered only when a declaration is added,
|
||||||
|
so a newly appearing family does not create another target automatically.
|
||||||
|
Discovery failure or a successful lookup returning no addresses rejects the
|
||||||
|
whole target set. A missing family is normal and simply creates no target for
|
||||||
|
that family. Final labels must be unique, including generated `/v4` and `/v6`
|
||||||
|
labels; choose another alias if one collides with an explicit label.
|
||||||
|
|
||||||
|
`-4`, `-6`, and `--dual-stack` are mutually exclusive. Expanded targets use
|
||||||
|
ordinary multi-target pacing and each sends its own probes, so dual-stack can
|
||||||
|
double the number of probes per interval compared with one target per hostname.
|
||||||
|
|
||||||
|
## Local socket configuration
|
||||||
|
|
||||||
|
If you need to control where probes leave the machine, use these socket
|
||||||
|
options:
|
||||||
|
|
||||||
|
- `--bind-addr IP[:PORT]` binds sockets to a local IP address and optional
|
||||||
|
port. Leave out the port or use `0` to let the OS assign an ephemeral port.
|
||||||
|
IPv6 addresses with a port need `[ADDR]:PORT` syntax. The address family
|
||||||
|
must match the target's resolved address family.
|
||||||
|
- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a
|
||||||
|
network interface using `SO_BINDTODEVICE`.
|
||||||
|
- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark
|
||||||
|
using `SO_MARK`, so policy routing or firewall rules can select the
|
||||||
|
traffic. It accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit
|
||||||
|
integer.
|
||||||
|
- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. Supply
|
||||||
|
the FIB as a decimal unsigned 32-bit integer.
|
||||||
|
|
||||||
|
For example, to bind IPv4 probes to a specific source address and interface
|
||||||
|
with a firewall mark:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client -4 --bind-addr 192.0.2.10 --bind-device wan0 --mark 1234 host.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting a firewall mark or binding to a network device may require elevated
|
||||||
|
privileges or appropriate capabilities. If the OS rejects a socket option,
|
||||||
|
socket setup fails.
|
||||||
|
|
||||||
|
A fixed local port can cause bind conflicts when the client creates sockets
|
||||||
|
for multiple targets. Unless you need a particular source port, let the OS
|
||||||
|
assign one. A bind address for one family also cannot be used with targets
|
||||||
|
in the other family.
|
||||||
|
|
||||||
|
## Dynamic target sets from standard input
|
||||||
|
|
||||||
|
`--targets-stdin` only works in continuous mode (`--continuous` or
|
||||||
|
`--duration 0`). Use it to change targets while the client is running.
|
||||||
|
Positional targets form the initial set. You can leave them out to start
|
||||||
|
with none:
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
irtt-client --duration 0 --targets-stdin
|
irtt-client --duration 0 --targets-stdin
|
||||||
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
|
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
Each non-empty stdin record declares the complete desired target set, rather
|
Each non-empty stdin record gives the complete set of targets you want.
|
||||||
than a delta. A later record replaces an earlier set: targets absent from it
|
A new record replaces the previous set: the client retires targets you leave
|
||||||
are retired, unchanged target configurations retain their generation, and a
|
out, keeps the generation for unchanged configurations, and starts a fresh
|
||||||
changed address or HMAC setting creates a fresh generation. Under transient
|
generation when an address or HMAC setting changes. If live-generation
|
||||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
capacity temporarily prevents an update, only the latest unapplied set is
|
||||||
|
kept.
|
||||||
|
|
||||||
Commas frame targets in stdin records; escape a literal comma within one
|
The same address-family preparation applies to initial and stdin targets.
|
||||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
With `--dual-stack`, unchanged labels and endpoints retain their discovered
|
||||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
families across records, including HMAC-only changes. Removing a declaration
|
||||||
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
|
and later adding it again performs fresh discovery. New declarations are
|
||||||
client is being controlled. For example, in one terminal:
|
prepared asynchronously while existing measurements continue. A preparation
|
||||||
|
error stops the stream gracefully without applying a partial set.
|
||||||
|
|
||||||
|
Separate targets in stdin records with commas, and escape a comma inside a
|
||||||
|
target as `\,`. Each target otherwise uses the same syntax as positional
|
||||||
|
arguments, including the escapes above, `@hmac=KEY`, and `@hmac=`.
|
||||||
|
|
||||||
|
Keep the producer or pipe/FIFO writer open while controlling the client.
|
||||||
|
For example, run this in one terminal:
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
mkfifo /tmp/irtt-targets
|
mkfifo /tmp/irtt-targets
|
||||||
@@ -87,8 +171,8 @@ irtt-client --duration 0 --targets-stdin --hmac default-secret \
|
|||||||
< /tmp/irtt-targets
|
< /tmp/irtt-targets
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
Then open the FIFO once from another terminal and keep that descriptor open
|
Then, in another terminal, open the FIFO once and keep that descriptor open
|
||||||
while publishing complete desired sets:
|
while sending complete target sets:
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
exec 3> /tmp/irtt-targets
|
exec 3> /tmp/irtt-targets
|
||||||
@@ -98,39 +182,129 @@ printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
|
|||||||
exec 3>&-
|
exec 3>&-
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
The exact record `[]` selects an empty desired set. Empty records are
|
Send exactly `[]` to select an empty target set. The client ignores empty
|
||||||
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
|
records, but otherwise treats whitespace as part of the input without
|
||||||
or closure of the control plane and requests a graceful stop. A desired-state
|
trimming it.
|
||||||
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
|
Closing stdin sends EOF and asks the client to stop gracefully. An update
|
||||||
revision to become applicable.
|
that has been read but not yet acknowledged as applied may be discarded
|
||||||
|
at that point. EOF does not wait for an update blocked by capacity to
|
||||||
|
become applicable.
|
||||||
|
|
||||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
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
|
desired set to 128 targets and retains at most 256 live target generations
|
||||||
while replacements drain.
|
while replacements drain. The 128-target limit applies after dual-stack
|
||||||
|
expansion: a hostname producing both families consumes two target slots.
|
||||||
|
|
||||||
## FINITE VERSUS CONTINUOUS OPERATION
|
## Negotiation only
|
||||||
|
|
||||||
|
`-n` / `--no-test` connects to each target, negotiates the requested parameters,
|
||||||
|
and exits without sending measurement probes. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client --no-test host.example:2112 --duration 30s --interval 100ms
|
||||||
|
```
|
||||||
|
|
||||||
|
On successful negotiation, the `no_test_completed` event includes the
|
||||||
|
negotiated duration, interval, and packet length. Output format and column
|
||||||
|
selection work as usual. The client prints no measurement summary or
|
||||||
|
statistics-memory warning. Negotiation failures use the normal target
|
||||||
|
diagnostics and exit policy: an uninterrupted run exits nonzero if no
|
||||||
|
target succeeds.
|
||||||
|
|
||||||
|
Duration options still specify the duration to negotiate; even with
|
||||||
|
`--continuous` or `--duration 0`, the client exits after negotiation.
|
||||||
|
`--no-test` cannot be combined with `--targets-stdin`.
|
||||||
|
|
||||||
|
## Finite versus continuous operation
|
||||||
|
|
||||||
|
`--duration` and `--interval` accept fractional and composite durations, such as
|
||||||
|
`2.5s`, `1m30s`, and `1h`, as well as `ms`, `us` (or `µs`), and `ns` units
|
||||||
|
(e.g. `500us` or `100ns`). A nonzero value requires a unit. The interval must
|
||||||
|
be greater than zero; a zero test duration, including `0s`, selects continuous
|
||||||
|
mode.
|
||||||
|
|
||||||
|
`--timeouts 1s,2s,4s,8s` sets the session-opening retry schedule. Each
|
||||||
|
comma-separated duration is the receive timeout for one open attempt; attempts
|
||||||
|
stop when a valid open reply arrives. The list must be nonempty, with each
|
||||||
|
value at least `200ms`, and uses the same duration syntax as `--interval`.
|
||||||
|
Omitting the option preserves the library default schedule.
|
||||||
|
|
||||||
`--duration` controls the run length:
|
`--duration` controls the run length:
|
||||||
|
|
||||||
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
|
- A nonzero duration (default `10s`) gives you a finite run. Probing stops
|
||||||
after that long, and a final summary is always printed for table output
|
after that long. If statistics are enabled and you're using table
|
||||||
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
|
[output](#output), the client prints a final summary. CSV, TSV, and
|
||||||
- `--duration 0` is **continuous**: the client runs until interrupted
|
JSON Lines never include a summary.
|
||||||
(`Ctrl-C`). For table output, a final summary is printed only if the run
|
- With `--continuous` (or `--duration 0`), the client runs until interrupted
|
||||||
was interrupted. Stdin-controlled mode is the exception: EOF also requests
|
(`Ctrl-C`). With statistics and table output enabled, interrupting the run
|
||||||
a graceful stop and prints the final retained summary. An uninterrupted
|
prints a final summary. In stdin-controlled mode, EOF also stops the run
|
||||||
continuous run that exits for another reason does not get one.
|
gracefully and prints the retained summary. A continuous run that exits
|
||||||
|
for another reason without being interrupted does not print one.
|
||||||
|
|
||||||
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
The choice also affects which statistics are available and how much memory
|
||||||
distinction also determines what statistics can be computed and how much
|
a long run retains. See [Measurements and memory](#measurements-and-memory).
|
||||||
memory a long run retains.
|
|
||||||
|
|
||||||
## OUTPUT
|
### Optional recovery and event-only runs
|
||||||
|
|
||||||
|
If you only need individual measurements, use `--no-stats` to disable
|
||||||
|
statistics collection and final summaries. Columns that need statistics,
|
||||||
|
such as IPDV, are unavailable. The runtime flag still leaves the
|
||||||
|
`irtt-stats` dependency in the build.
|
||||||
|
|
||||||
|
To omit statistics at compile time, build with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p irtt-rs --bin irtt-client --no-default-features --features client
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--features client,stats` to include statistics in a client-only build.
|
||||||
|
Default builds include statistics, and `tui` always enables them. Without
|
||||||
|
compiled statistics, `--no-stats` remains accepted, summaries and statistics
|
||||||
|
warnings are disabled, and IPDV columns retain their names with absent values
|
||||||
|
(`-` in tables, empty CSV/TSV fields, and JSON `null`). Measurement events and
|
||||||
|
protocol-level received statistics remain available.
|
||||||
|
|
||||||
|
Use `--reconnect` to recover individual targets in continuous mode
|
||||||
|
(`--continuous` or `--duration 0`). The client retries failed connections,
|
||||||
|
closed sessions, and unresponsive servers while the other targets keep
|
||||||
|
running. By default, the delay doubles after each failed attempt, from
|
||||||
|
250 ms up to 10 seconds. A valid reply resets it to the initial delay.
|
||||||
|
Each target backs off independently.
|
||||||
|
|
||||||
|
You can change the delays with `--reconnect-initial-delay DURATION`
|
||||||
|
(default: `250ms`) and `--reconnect-max-delay DURATION` (default: `10s`).
|
||||||
|
Both require `--reconnect` and must be positive and schedulable. The maximum
|
||||||
|
must be at least twice the initial delay. For example,
|
||||||
|
`--reconnect-initial-delay 1s --reconnect-max-delay 5s` retries after `1s`,
|
||||||
|
`2s`, `4s`, `5s`, then `5s` from there on.
|
||||||
|
|
||||||
|
`--reconnect-timeout DURATION` sets how long a session can go without valid
|
||||||
|
replies before the client tries to reconnect (default: `10s`). It only
|
||||||
|
applies while actively sending probes, so allow for your probe interval and
|
||||||
|
expected packet loss.
|
||||||
|
|
||||||
|
Reconnecting resets statistics for that target, so its final summary only
|
||||||
|
covers the latest session. The client warns about this when reconnection
|
||||||
|
and statistics collection are both enabled. Use `--no-stats` to disable
|
||||||
|
collection and suppress the warning.
|
||||||
|
|
||||||
|
For continuous CSV output from multiple targets without statistics:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client --continuous --reconnect --no-stats \
|
||||||
|
--reconnect-timeout 10s \
|
||||||
|
--reconnect-initial-delay 250ms --reconnect-max-delay 10s \
|
||||||
|
--format csv --header never \
|
||||||
|
--columns client_receive_wall_ns,target,seq,rd_us,sd_us \
|
||||||
|
reflector1 reflector2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
### `--format FORMAT`
|
### `--format FORMAT`
|
||||||
|
|
||||||
One of four event-row formats, default `table`:
|
Choose one of four event-row formats. The default is `table`:
|
||||||
|
|
||||||
- `table`: human-readable terminal output; the only format that prints a
|
- `table`: human-readable terminal output; the only format that prints a
|
||||||
final summary.
|
final summary.
|
||||||
@@ -138,23 +312,36 @@ One of four event-row formats, default `table`:
|
|||||||
- `tsv`: tab-separated output.
|
- `tsv`: tab-separated output.
|
||||||
- `jsonl`: one JSON object per event.
|
- `jsonl`: one JSON object per event.
|
||||||
|
|
||||||
CSV, TSV, and JSON Lines default to **all** columns for structured export;
|
CSV, TSV, and JSON Lines include all columns by default for structured export;
|
||||||
`table` defaults to a compact column set and hides `echo_sent` rows. An
|
`table` defaults to a compact column set and hides `echo_sent` events.
|
||||||
explicit `--columns` selection other than `default` shows all event rows,
|
You can show those events by explicitly selecting them with `--events`
|
||||||
including `echo_sent`; `--columns default` is equivalent to omitting the
|
or by using custom columns.
|
||||||
option and keeps `echo_sent` hidden for table output.
|
|
||||||
|
### `--events EVENTS`
|
||||||
|
|
||||||
|
Choose which events to print with a comma-separated list, or repeat the
|
||||||
|
option to select more events. The available names are `session_started`,
|
||||||
|
`no_test_completed`, `session_closed`, `echo_sent`, `echo_reply`, `loss`,
|
||||||
|
`duplicate`, `late`, and `warning`. Without this option, the client prints
|
||||||
|
all events normally shown by the selected format.
|
||||||
|
|
||||||
|
For CSV output containing only echo replies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client <server> --events echo_reply --format csv --header never --no-stats
|
||||||
|
```
|
||||||
|
|
||||||
### `--columns COLUMNS` / `-c COLUMNS`
|
### `--columns COLUMNS` / `-c COLUMNS`
|
||||||
|
|
||||||
Comma-separated event row columns, or the special values `default` (the
|
Choose event-row columns with a comma-separated list, or use `default`
|
||||||
format's default set) or `all` (every column). List every available column,
|
for the format's default set and `all` for every column. To see the
|
||||||
its meaning, and its aliases with:
|
available columns, their meanings, and aliases, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client --list-columns
|
irtt-client --list-columns
|
||||||
```
|
```
|
||||||
|
|
||||||
(`--list-columns` does not require a target.)
|
You don't need a target for `--list-columns`.
|
||||||
|
|
||||||
### `--header auto|always|never`
|
### `--header auto|always|never`
|
||||||
|
|
||||||
@@ -163,10 +350,9 @@ Lines never prints a header line.
|
|||||||
|
|
||||||
### `--verbose`
|
### `--verbose`
|
||||||
|
|
||||||
Includes extra fields in table output and final summaries. Has no effect on
|
Include extra fields in table output and final summaries. CSV, TSV, and
|
||||||
CSV, TSV, or JSON Lines output either way — those formats show their default
|
JSON Lines keep their default all-column set or your explicit `--columns`
|
||||||
all-column set or an explicit `--columns` selection exactly as given,
|
selection regardless of `--verbose`.
|
||||||
regardless of `--verbose`.
|
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
@@ -176,13 +362,13 @@ irtt-client <server> --format jsonl
|
|||||||
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us
|
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us
|
||||||
```
|
```
|
||||||
|
|
||||||
A stream containing only effective RTT values in microseconds:
|
To print only effective RTT values in microseconds:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client <server> --format tsv --columns effective_rtt_us --header never
|
irtt-client <server> --format tsv --columns effective_rtt_us --header never
|
||||||
```
|
```
|
||||||
|
|
||||||
## MEASUREMENT FIELDS
|
## Measurement fields
|
||||||
|
|
||||||
Useful measurement columns:
|
Useful measurement columns:
|
||||||
|
|
||||||
@@ -203,90 +389,95 @@ Useful measurement columns:
|
|||||||
|
|
||||||
### Kernel timestamps (Linux)
|
### Kernel timestamps (Linux)
|
||||||
|
|
||||||
On Linux, one-way delay estimates prefer a kernel-captured timestamp over
|
On Linux, the client prefers kernel timestamps for its own send/receive
|
||||||
userspace timing for this client's own send/receive endpoint, where the
|
endpoint when calculating one-way delays, where the platform and socket
|
||||||
platform and socket support it, reducing the effect of scheduling and
|
support them. This reduces the effect of scheduling and queuing delay.
|
||||||
queuing delay. The peer's reported endpoint gets the same preference when it
|
The `irtt-server` applet makes the same choice for its endpoint on Linux.
|
||||||
is the `irtt-server` applet running on Linux; against another
|
With another IRTT-compatible server, the client uses the wall-clock value
|
||||||
IRTT-compatible server, whatever wall-clock value it reports is used as-is.
|
that server reports 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
|
If a kernel timestamp is unsupported or implausible, timing falls back to
|
||||||
|
userspace. This leaves `raw_rtt_us` unchanged. The
|
||||||
|
`adjusted_rtt_us`/`effective_rtt_us` values can still shift slightly, since
|
||||||
|
server processing time uses the same server receive endpoint.
|
||||||
|
|
||||||
Finite and continuous modes make a different tradeoff between exact
|
## Measurements and memory
|
||||||
statistics and bounded memory.
|
|
||||||
|
|
||||||
**Finite mode** retains exact timing samples (and adjacent-sequence IPDV
|
If the `stats` feature is compiled in and you haven't used `--no-stats`,
|
||||||
state) so the final summary can report exact medians. Retained memory
|
the client collects statistics as described here. Disabling collection
|
||||||
therefore grows with the number of probes sent, at a small constant cost per
|
allocates no statistics collectors, though the client still keeps its
|
||||||
probe (on the order of a few hundred bytes of retained sample state per
|
bounded reply-classification state.
|
||||||
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
|
In finite mode, the client keeps exact timing samples and adjacent-sequence
|
||||||
history: running statistics keep count/min/max/mean/variance only, so exact
|
IPDV state so it can report exact medians in the final summary. Memory
|
||||||
medians are unavailable. It still keeps a bounded adjacent-sequence IPDV
|
grows with the number of probes sent, at roughly a few hundred bytes per
|
||||||
store, capped at 4,096 sequences per target, so nearby replies can still
|
probe per target before allocator overhead. So if you're planning a long
|
||||||
form IPDV pairs. Retained statistics state therefore approaches a fixed
|
finite run with a short interval, allow for that growth.
|
||||||
per-target bound rather than growing with elapsed run time.
|
|
||||||
|
|
||||||
Reply classification (independent of the statistics choice above) is also
|
The client prints a warning on stderr when its estimate of retained state
|
||||||
bounded per target: each of the pending, timed-out, and completed/duplicate
|
crosses roughly 128 MiB, with stronger warnings past roughly 512 MiB and
|
||||||
sequence stores has its own 4,096-entry limit — not one shared total across
|
1 GiB. These are useful planning figures, but they don't promise an RSS
|
||||||
the three. A full pending store makes the client fail and drain that target
|
ceiling: allocator and OS overhead are outside the estimate.
|
||||||
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**
|
In continuous mode (`--duration 0`), running statistics keep only
|
||||||
and these fixed per-target limits, not with total probes sent in continuous
|
count/min/max/mean/variance, so exact medians are unavailable. The client
|
||||||
mode. Output is written as events arrive rather than accumulated; the
|
still keeps up to 4,096 sequences per target for adjacent-sequence IPDV
|
||||||
managed event stream feeding it is itself bounded and lossy (16,384 events),
|
pairs. Statistics memory approaches a fixed limit per target as the run
|
||||||
so a sufficiently slow consumer can miss events, in which case the client
|
continues.
|
||||||
reports that resulting output/statistics may be incomplete.
|
|
||||||
|
Reply classification has its own bounds, regardless of whether you collect
|
||||||
|
statistics. Each target has separate pending, timed-out, and
|
||||||
|
completed/duplicate sequence stores, each with a 4,096-entry limit. If the
|
||||||
|
pending store fills, the client fails and drains that target as
|
||||||
|
resource-exhausted. It does not wait for space to free up.
|
||||||
|
|
||||||
|
The timed-out and completed stores evict their oldest entries. A very late
|
||||||
|
reply can still be seen and counted after eviction, but without its retained
|
||||||
|
send state the client cannot calculate measurements such as RTT.
|
||||||
|
|
||||||
|
In continuous mode, retained state scales with the number of configured
|
||||||
|
targets and these fixed limits per target. The client writes output as
|
||||||
|
events arrive. The managed event stream has a 16,384-event capacity and can
|
||||||
|
drop events if the consumer falls behind. If that happens, the client
|
||||||
|
reports that output and statistics may be incomplete.
|
||||||
|
|
||||||
For capacity planning on constrained devices (for example an OpenWrt
|
For capacity planning on constrained devices (for example an OpenWrt
|
||||||
router): a finite run's statistics memory is roughly proportional to
|
router): a finite run's statistics memory is roughly proportional to
|
||||||
`targets × probes_sent`, while a continuous run's statistics memory
|
`targets × probes_sent`, while a continuous run's statistics memory
|
||||||
approaches a fixed multiple of `targets` regardless of how long it runs.
|
approaches a fixed multiple of `targets` regardless of how long it runs.
|
||||||
|
|
||||||
## PEER CLOSE AND EXIT BEHAVIOR
|
## Peer close and exit behavior
|
||||||
|
|
||||||
A peer closing a finite run is accepted as a terminal, successful outcome.
|
If a peer closes a finite run, the client treats that as successful
|
||||||
In continuous mode, an uninterrupted peer closure is treated as an error —
|
completion. In continuous mode without `--reconnect`, a peer closure ends
|
||||||
the client exits nonzero — so a process supervisor can restart it; an
|
an uninterrupted run with a nonzero exit status so a process supervisor
|
||||||
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
can restart it. If you interrupted the run (`Ctrl-C`), peer closure is
|
||||||
mode it was in.
|
never reported as an error in either mode.
|
||||||
|
|
||||||
With `--targets-stdin`, peer closure is target-local: other desired targets
|
With `--reconnect`, a desired target reopens automatically after peer closure.
|
||||||
and the stdin controller continue running. Including that target in a later
|
Without recovery, `--targets-stdin` makes peer closure target-local: other
|
||||||
complete set starts a fresh generation.
|
desired targets and the stdin controller continue running. Including that
|
||||||
|
target in a later complete set starts a fresh generation.
|
||||||
|
|
||||||
## EXIT STATUS
|
## Exit status
|
||||||
|
|
||||||
`0` on success (including a finite run's ordinary completion, and an
|
The client exits `0` on success, including normal finite completion and
|
||||||
interrupted run of either mode). Nonzero if the managed driver failed, if an
|
interrupted runs in either mode. It exits nonzero if the managed driver
|
||||||
uninterrupted continuous run ended by peer closure, or if the run was not
|
fails, an uninterrupted continuous run ends by peer closure, or no target
|
||||||
interrupted and no target completed successfully. An interrupted run never
|
completes successfully in an uninterrupted run.
|
||||||
fails on the last of these conditions — for example, interrupting a
|
|
||||||
continuous run whose only target never opened still exits `0`.
|
|
||||||
|
|
||||||
## SERVER FILL REQUEST
|
Interrupting the run prevents the last condition from counting as a failure.
|
||||||
|
For example, if you interrupt a continuous run whose only target never
|
||||||
|
opened, it still exits `0`.
|
||||||
|
|
||||||
|
## Server fill request
|
||||||
|
|
||||||
`--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
|
`--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
|
||||||
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
|
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
|
||||||
hexadecimal pattern). See `irtt-server(1)` SERVER FILL for what the server
|
hexadecimal pattern). See the Server fill section in `irtt-server(1)` for
|
||||||
does with an unset or unparseable request.
|
what the server does with an unset or unparseable request.
|
||||||
|
|
||||||
## EXAMPLES
|
## Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client netperf-eu.bufferbloat.net:2112
|
irtt-client netperf-eu.bufferbloat.net:2112
|
||||||
@@ -295,7 +486,7 @@ irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
|||||||
irtt-client host-a:2112 host-b:2112 --pacing burst
|
irtt-client host-a:2112 host-b:2112 --pacing burst
|
||||||
```
|
```
|
||||||
|
|
||||||
## SEE ALSO
|
## See also
|
||||||
|
|
||||||
`irtt-tui(1)`, `irtt-server(1)`, `irtt-rs(1)`, and `irtt-client --help` for
|
`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
|
the full option list, including HMAC, clock/timestamp negotiation, DSCP, and
|
||||||
|
|||||||
+38
-37
@@ -1,10 +1,10 @@
|
|||||||
# irtt-rs
|
# irtt-rs
|
||||||
|
|
||||||
## NAME
|
## Name
|
||||||
|
|
||||||
irtt-rs - IRTT-compatible multi-applet dispatcher
|
irtt-rs - IRTT-compatible multi-applet dispatcher
|
||||||
|
|
||||||
## SYNOPSIS
|
## Synopsis
|
||||||
|
|
||||||
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
|
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
|
||||||
|
|
||||||
@@ -12,53 +12,54 @@ irtt-rs - IRTT-compatible multi-applet dispatcher
|
|||||||
|
|
||||||
`irtt-rs` `server` [*OPTIONS*]
|
`irtt-rs` `server` [*OPTIONS*]
|
||||||
|
|
||||||
## DESCRIPTION
|
## Description
|
||||||
|
|
||||||
`irtt-rs` is a multicall dispatcher: one binary that runs the client, TUI,
|
`irtt-rs` lets you run the client, TUI, or server from one binary. You choose
|
||||||
or server applet depending on the subcommand given, or on the name it was
|
the applet with a subcommand, or by the name you invoke or copy the binary
|
||||||
invoked/copied as. It exists for router-friendly single-binary deployment;
|
as. This is useful on a router where you want a single binary to deploy.
|
||||||
it does not add behavior beyond what `irtt-client(1)`, `irtt-tui(1)`, and
|
The applets behave the same as `irtt-client(1)`, `irtt-tui(1)`, and
|
||||||
`irtt-server(1)` already provide.
|
`irtt-server(1)`.
|
||||||
|
|
||||||
Each subcommand behaves exactly like its dedicated binary — `irtt-rs client
|
For example, `irtt-rs client ...` does the same thing as running `irtt-client ...` directly. See the
|
||||||
...` is equivalent to running `irtt-client ...` directly. See those manuals
|
individual manuals for target syntax, options, output, and exit behavior.
|
||||||
for target syntax, options, output, and exit behavior.
|
|
||||||
|
|
||||||
## APPLET BINARY NAMES
|
## Applet binary names
|
||||||
|
|
||||||
The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
|
The build also produces dedicated `irtt-client`, `irtt-tui`, and
|
||||||
`irtt-server`. A dedicated binary always runs its own role, regardless of
|
`irtt-server` binaries. These always run their own applet, even if you copy
|
||||||
what it is invoked or copied as — it does not consult argv0 dispatch logic.
|
or invoke them under a different name. They do not use argv0 to choose an
|
||||||
|
applet.
|
||||||
|
|
||||||
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
|
The `irtt-rs` dispatcher checks argv0 first. If you invoke it through a
|
||||||
one of the recognized applet names — `irtt-client`, `irtt-tui`, or
|
symlink or copy named `irtt-client`, `irtt-tui`, or `irtt-server`, it runs
|
||||||
`irtt-server` — it runs that applet directly, without needing a subcommand.
|
that applet without 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
|
Other names starting with `irtt-`, except `irtt-rs` itself, are rejected
|
||||||
|
before subcommands are parsed. So `irtt-custom server` fails with an unknown
|
||||||
|
applet error. If the name is `irtt-rs`, or doesn't start with `irtt-`, the
|
||||||
|
dispatcher parses the usual `client`/`tui`/`server` subcommands.
|
||||||
|
|
||||||
Each applet is gated by a Cargo feature on the `irtt-rs` package: `client`,
|
## Feature-dependent applet availability
|
||||||
`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
|
Each applet has a Cargo feature on the `irtt-rs` package: `client`,
|
||||||
unavailable at runtime — running it (by subcommand or by binary name)
|
`tui`, `server`. All three are enabled by default. If you disable one, the
|
||||||
reports which feature to rebuild with, and `irtt-rs --help` lists which
|
build still produces `irtt-rs`, but you cannot run that applet. Trying it
|
||||||
applets the current build actually contains.
|
through either a subcommand or a binary name tells you which feature to
|
||||||
|
rebuild with. Use `irtt-rs --help` to see which applets your build includes.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install irtt-rs --locked --no-default-features --features client
|
cargo install irtt-rs --locked --no-default-features --features client,stats
|
||||||
```
|
```
|
||||||
|
|
||||||
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
|
Use `--features client` to omit statistics. The default build includes the
|
||||||
|
`stats` feature, and `tui` always enables it.
|
||||||
|
|
||||||
`irtt-rs` is packaging and dispatch only. All probing, display, and server
|
## Relationship to irtt-client, irtt-tui, irtt-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
|
The `irtt-rs` dispatcher handles packaging and applet selection. For how to run
|
||||||
|
probes, use the dashboard, or serve requests, see `irtt-client(1)`,
|
||||||
|
`irtt-tui(1)`, and `irtt-server(1)`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
|
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
|
||||||
+105
-104
@@ -1,22 +1,22 @@
|
|||||||
# irtt-server
|
# irtt-server
|
||||||
|
|
||||||
## NAME
|
## Name
|
||||||
|
|
||||||
irtt-server - IRTT-compatible UDP server
|
irtt-server - IRTT-compatible UDP server
|
||||||
|
|
||||||
## SYNOPSIS
|
## Synopsis
|
||||||
|
|
||||||
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
|
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
|
||||||
|
|
||||||
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
|
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
|
||||||
|
|
||||||
## DESCRIPTION
|
## Description
|
||||||
|
|
||||||
`irtt-server` answers IRTT echo requests over UDP. It is thin orchestration
|
`irtt-server` answers IRTT echo requests over UDP. It uses the reusable
|
||||||
over the reusable `irtt-server` library: one current-thread Tokio runtime,
|
`irtt-server` library for sessions and policy, with one current-thread
|
||||||
one or more listeners, and the library's own session/policy logic.
|
Tokio runtime running one or more listeners.
|
||||||
|
|
||||||
Run with no arguments for the ordinary case:
|
To start a server with the defaults, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-server
|
irtt-server
|
||||||
@@ -28,34 +28,31 @@ wildcard address for both address families, in this fixed order:
|
|||||||
1. `[::]:2112`
|
1. `[::]:2112`
|
||||||
2. `0.0.0.0:2112`
|
2. `0.0.0.0:2112`
|
||||||
|
|
||||||
This is available wherever the platform supports safe wildcard reply-source
|
The defaults work on platforms that support wildcard reply-source
|
||||||
selection (see [Wildcard binds](#wildcard-binds) below). Any explicit
|
selection (see [Wildcard binds](#wildcard-binds)). If you supply any
|
||||||
`--bind` **replaces** this default pair entirely rather than adding to it —
|
`--bind`, it replaces both defaults. For example,
|
||||||
`irtt-server --bind 127.0.0.1:2112` binds only that one address.
|
`irtt-server --bind 127.0.0.1:2112` listens only on that address.
|
||||||
|
|
||||||
If one of the two default addresses cannot be bound because its address
|
If the host has no support for one address family, for example because IPv6
|
||||||
family itself has no local support at all — IPv6 disabled on an otherwise
|
is disabled, the server uses the other default listener and tells you on
|
||||||
IPv4-capable host, for example — the server falls back to serving just the
|
startup. Other bind failures still stop startup. This includes a port
|
||||||
other one instead of failing outright, and says so on startup. Any other
|
already in use, permission denied, or a platform without safe wildcard
|
||||||
reason a default listener fails — the port already in use, permission
|
reply-source selection.
|
||||||
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
|
The address-family fallback only applies to the default pair. An explicit
|
||||||
|
`--bind` list always uses the all-or-nothing behavior described below.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
### `--bind ADDR`
|
### `--bind ADDR`
|
||||||
|
|
||||||
Local UDP address to bind, as `ADDR:PORT`, for example `127.0.0.1:2112` or
|
Use `ADDR:PORT` to choose a local UDP address, for example `127.0.0.1:2112`
|
||||||
`[::1]:2112`. Host names are not resolved. Repeatable.
|
or `[::1]:2112`. Hostnames are not resolved.
|
||||||
|
|
||||||
Repeat the option to serve several addresses from one process, in the order
|
Repeat `--bind` to serve several addresses from one process, in the order
|
||||||
given. Every listener applies the same policy options, but each keeps its
|
given. Every listener uses the same policy options, but keeps its own
|
||||||
own sessions and tokens, so a session belongs to the address it was opened
|
sessions and tokens. A session belongs to the address it was opened on.
|
||||||
on. Binding is all-or-nothing: if any address cannot be bound, none are
|
Binding is all-or-nothing: if any address cannot be bound, none are served.
|
||||||
served.
|
|
||||||
|
|
||||||
A port of `0` selects an unused port per listener, so two such binds get two
|
A port of `0` selects an unused port per listener, so two such binds get two
|
||||||
different ports.
|
different ports.
|
||||||
@@ -63,70 +60,75 @@ different ports.
|
|||||||
With no `--bind` at all, see [Description](#description) above for the
|
With no `--bind` at all, see [Description](#description) above for the
|
||||||
default wildcard pair and the address-family fallback it allows.
|
default wildcard pair and the address-family fallback it allows.
|
||||||
|
|
||||||
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
|
If you need DSCP/traffic-class marking on replies to IPv4 peers, use care
|
||||||
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
|
with a lone dual-stack `[::]:PORT` listener. On Linux, those peers use
|
||||||
served through that socket are IPv4-mapped and their replies cannot retain the
|
IPv4-mapped addresses and their replies cannot retain the negotiated
|
||||||
negotiated marking. Configure the ordinary paired `[::]:PORT` and
|
marking. Use paired `[::]:PORT` and `0.0.0.0:PORT` listeners on the same
|
||||||
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
|
port. The server makes the IPv6 listener IPv6-only for that pair, so each
|
||||||
same-port pair, so each family uses the correct socket option. This is a
|
family gets the correct socket option. A lone IPv6 listener keeps its usual
|
||||||
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
|
behavior; the server does not force `IPV6_V6ONLY` on it.
|
||||||
IPv6 listener.
|
|
||||||
|
|
||||||
### `--hmac KEY`
|
### `--hmac KEY`
|
||||||
|
|
||||||
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
|
You can use a plain-text key or a hexadecimal key prefixed with `0x`.
|
||||||
every request must carry a valid MAC and every reply is authenticated;
|
For example, `secret` and `0x736563726574` are equivalent. The
|
||||||
without one, authenticated requests are dropped. The key is visible in the
|
`IRTT_SERVER_HMAC` environment variable accepts the same syntax.
|
||||||
process arguments.
|
|
||||||
|
With a key configured, every request must carry a valid MAC and the server
|
||||||
|
authenticates every reply. Without a key, the server drops authenticated
|
||||||
|
requests. A key passed on the command line is visible in the process
|
||||||
|
arguments.
|
||||||
|
|
||||||
### `--max-sessions COUNT`
|
### `--max-sessions COUNT`
|
||||||
|
|
||||||
Maximum number of simultaneously live sessions **per listener**, not per
|
Set the maximum number of live sessions per listener. This limit applies
|
||||||
process. Once a listener's table is full, a session-creating open to it is
|
to each listener separately, so the process can have more sessions in total.
|
||||||
dropped silently; nothing is evicted. Zero refuses every session-creating
|
Once a listener's table is full, it silently drops opens that would create
|
||||||
open.
|
a session and keeps the existing sessions. Zero refuses every open that
|
||||||
|
would create a session.
|
||||||
|
|
||||||
### `--max-packet-length BYTES`
|
### `--max-packet-length BYTES`
|
||||||
|
|
||||||
Maximum echo datagram size a session may negotiate, in bytes. A longer
|
Set the maximum echo datagram size a session may negotiate, in bytes. The
|
||||||
requested length is reduced during negotiation, an open whose mandatory
|
server reduces longer requests during negotiation and refuses an open if
|
||||||
field block would not fit is refused, and inbound echo requests are admitted
|
its mandatory fields would not fit. Incoming echo requests must fit the
|
||||||
against the same limit. This is a resource bound, not an MTU.
|
same limit. Use this to bound resource use; it does not set the network MTU.
|
||||||
|
|
||||||
### `--min-interval DURATION`
|
### `--min-interval DURATION`
|
||||||
|
|
||||||
Floor on the send interval a session may negotiate. The negotiated interval
|
Set the minimum send interval a session may negotiate. The server then caps
|
||||||
is still capped at a quarter of the idle timeout afterwards, so a
|
the negotiated interval at a quarter of the idle timeout, so a
|
||||||
`--min-interval` above that cap is not the value actually negotiated.
|
`--min-interval` above that cap will be reduced.
|
||||||
|
|
||||||
A session's reply allowance refills at the shorter of this configured floor
|
The session's reply allowance refills at the shorter of the configured
|
||||||
and the interval it actually negotiated. Ordinarily that is the configured
|
minimum and the negotiated interval. Usually this is the configured
|
||||||
floor itself, since the negotiated interval is normally at least as long —
|
minimum: a 10ms `--min-interval` with a 1s negotiated interval refills every
|
||||||
a 10ms `--min-interval` against a 1s negotiated interval refills every
|
10ms.
|
||||||
10ms. The two differ only when the idle-timeout cap above pulled the
|
|
||||||
negotiated interval below the configured floor, in which case the shorter,
|
If the idle-timeout cap reduces the negotiated interval below the configured
|
||||||
negotiated value is what the allowance actually refills at: a server must
|
minimum, the allowance refills at that shorter interval too. This keeps the
|
||||||
not enforce a cadence it did not advertise to the client. Use `0` for no
|
server from enforcing a slower cadence than it told the client to use.
|
||||||
time-based throttling.
|
Use `0` for no time-based throttling.
|
||||||
|
|
||||||
### `--burst COUNT`
|
### `--burst COUNT`
|
||||||
|
|
||||||
Echo replies one session may have answered before its allowance has to
|
Set how many echo replies a session can send before its allowance needs to
|
||||||
refill. Use `0` for no allowance at all, which rate-limits every echo
|
refill. Use `0` for no allowance, which rate-limits every
|
||||||
request whatever the interval is.
|
echo request regardless of the interval.
|
||||||
|
|
||||||
### `--idle-timeout DURATION`
|
### `--idle-timeout DURATION`
|
||||||
|
|
||||||
Release a session after this long without a served or rate-limited echo
|
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
|
request. The timeout starts at open, and the server releases the session
|
||||||
expire a session at the next evaluation; it is not a way to disable expiry.
|
without sending a close. If you use `0`, the session expires at the next
|
||||||
|
evaluation; zero does not disable expiry.
|
||||||
|
|
||||||
### `--max-duration DURATION`
|
### `--max-duration DURATION`
|
||||||
|
|
||||||
Maximum test duration a session may negotiate. A longer request is reduced
|
Set the maximum test duration a session may negotiate. The server reduces
|
||||||
to it, and a continuous request is answered with it. Omit this flag for no
|
longer requests to this value and uses it for continuous requests too.
|
||||||
maximum; a maximum of zero cannot be expressed, because a negotiated
|
Leave the flag out if you don't want a maximum. You cannot set a maximum of
|
||||||
duration of zero means continuous.
|
zero, since a negotiated duration of zero means continuous.
|
||||||
|
|
||||||
### `--timestamp-allowance MODE`
|
### `--timestamp-allowance MODE`
|
||||||
|
|
||||||
@@ -146,13 +148,12 @@ and they are sent unmarked. The session is not refused.
|
|||||||
Durations everywhere in this command accept an integer followed by a unit:
|
Durations everywhere in this command accept an integer followed by a unit:
|
||||||
`ms`, `s`, or `m`.
|
`ms`, `s`, or `m`.
|
||||||
|
|
||||||
## ENVIRONMENT
|
## Environment
|
||||||
|
|
||||||
Every option above can also be supplied through an environment variable, for
|
You can also set each option through an environment variable, which is
|
||||||
deployments that configure by environment rather than by command line
|
useful for containers, orchestrators, and unit files. A command-line flag
|
||||||
(containers, orchestrators, unit files). A flag given on the command line
|
overrides the corresponding variable entirely. If neither is set, the
|
||||||
overrides the corresponding variable entirely; an unset variable leaves the
|
server uses its built-in default.
|
||||||
built-in default in place.
|
|
||||||
|
|
||||||
| Variable | Option |
|
| Variable | Option |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -175,10 +176,10 @@ more than one address needs repeated `--bind` options.
|
|||||||
an empty value leave traffic-class marking enabled; any other value disables
|
an empty value leave traffic-class marking enabled; any other value disables
|
||||||
it, and an unset variable leaves it enabled.
|
it, and an unset variable leaves it enabled.
|
||||||
|
|
||||||
## OUTPUT
|
## Output
|
||||||
|
|
||||||
Each bound endpoint is printed on startup, once every listener is up, which
|
Once every listener is up, the server prints each bound endpoint. If you
|
||||||
also resolves a port of `0`:
|
requested port `0`, this shows which port was assigned:
|
||||||
|
|
||||||
```
|
```
|
||||||
irtt-server: listening on [::]:2112
|
irtt-server: listening on [::]:2112
|
||||||
@@ -187,21 +188,22 @@ irtt-server: listening on 0.0.0.0:2112
|
|||||||
|
|
||||||
`Ctrl-C` stops the server gracefully.
|
`Ctrl-C` stops the server gracefully.
|
||||||
|
|
||||||
## MULTIPLE LISTENERS
|
## Multiple listeners
|
||||||
|
|
||||||
Every invocation runs through the same multi-listener path, so one address —
|
The server manages listeners the same way whether you have one or several,
|
||||||
whether from the default pair or a single explicit `--bind` — is an ordinary
|
including when only one address from the default pair is available or you
|
||||||
set of one rather than a separate mode. The policy options above apply to
|
supply a single `--bind`. Each listener uses the same policy options, but
|
||||||
every listener, but listeners are otherwise independent: each has its own
|
keeps its own sessions and tokens. The `--max-sessions` limit applies to
|
||||||
sessions and tokens, and `--max-sessions` bounds each listener rather than
|
each listener separately.
|
||||||
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
|
If any listener fails while running, the server shuts down the others too.
|
||||||
on one.
|
So a service configured for both address families won't carry on answering
|
||||||
|
on just one after a listener failure.
|
||||||
|
|
||||||
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
|
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
|
||||||
does.
|
does.
|
||||||
|
|
||||||
## SERVER FILL
|
## Server fill
|
||||||
|
|
||||||
Echo replies fill their payload according to the negotiated `ServerFill`
|
Echo replies fill their payload according to the negotiated `ServerFill`
|
||||||
mode, which a client requests with `irtt-client`'s `--sfill` (see
|
mode, which a client requests with `irtt-client`'s `--sfill` (see
|
||||||
@@ -211,11 +213,11 @@ mode, which a client requests with `irtt-client`'s `--sfill` (see
|
|||||||
- `rand`: random bytes.
|
- `rand`: random bytes.
|
||||||
- `pattern:HEX`: the given repeating hexadecimal pattern.
|
- `pattern:HEX`: the given repeating hexadecimal pattern.
|
||||||
|
|
||||||
Every valid descriptor is honored. A descriptor the server cannot parse, and
|
The server honors every valid descriptor. If it cannot parse the descriptor,
|
||||||
a client that expresses no fill preference at all, both get the default
|
or the client sends no fill preference, it uses `pattern:69727474`: the
|
||||||
`pattern:69727474`, i.e. the repeating bytes `69 72 74 74` (`irtt`).
|
repeating bytes `69 72 74 74` (`irtt`).
|
||||||
|
|
||||||
## WILDCARD BINDS
|
## Wildcard binds
|
||||||
|
|
||||||
An explicit interface address works on every supported system. A wildcard
|
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
|
bind such as `0.0.0.0:2112` or `[::]:2112` reads each request's local
|
||||||
@@ -223,14 +225,13 @@ 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
|
same address, so a client on a multi-homed host is answered from the
|
||||||
endpoint it actually contacted.
|
endpoint it actually contacted.
|
||||||
|
|
||||||
That reply-source selection is implemented on **Linux, macOS, and FreeBSD**.
|
Reply-source selection is supported on Linux, macOS, and FreeBSD. On other
|
||||||
Elsewhere, a wildcard bind is refused at construction rather than served
|
platforms, the server refuses wildcard binds because the routing table
|
||||||
from a routing-table-selected source address a client would discard — this
|
could select a source address the client would discard. This also means
|
||||||
also applies to the zero-argument default, which will fail with a clear
|
running without arguments fails on those platforms, with an error asking
|
||||||
error asking for an explicit `--bind` on such a platform. This correctness
|
you to supply an explicit `--bind`.
|
||||||
rule is deliberate and is not weakened by the zero-argument default.
|
|
||||||
|
|
||||||
## EXAMPLES
|
## Examples
|
||||||
|
|
||||||
Run with the default wildcard listeners on the standard port:
|
Run with the default wildcard listeners on the standard port:
|
||||||
|
|
||||||
@@ -282,19 +283,19 @@ Require authentication:
|
|||||||
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
||||||
```
|
```
|
||||||
|
|
||||||
## EXIT STATUS
|
## Exit status
|
||||||
|
|
||||||
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
|
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
|
||||||
fails while running.
|
fails while running.
|
||||||
|
|
||||||
## LIMITATIONS
|
## Limitations
|
||||||
|
|
||||||
- No hostname resolution, interface expansion, daemonization, or
|
- No hostname resolution, interface expansion, daemonization, or
|
||||||
configuration file — binds are explicit addresses only.
|
configuration file. Binds must use explicit addresses.
|
||||||
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
|
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
|
||||||
FreeBSD; such platforms must bind explicit addresses.
|
FreeBSD; such platforms must bind explicit addresses.
|
||||||
|
|
||||||
## SEE ALSO
|
## See also
|
||||||
|
|
||||||
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
|
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
|
||||||
the full option list.
|
the full option list.
|
||||||
+150
-50
@@ -1,82 +1,182 @@
|
|||||||
# irtt-tui
|
# irtt-tui
|
||||||
|
|
||||||
## NAME
|
## Name
|
||||||
|
|
||||||
irtt-tui - IRTT-compatible terminal UI client
|
irtt-tui - IRTT-compatible terminal UI client
|
||||||
|
|
||||||
## SYNOPSIS
|
## Synopsis
|
||||||
|
|
||||||
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
|
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
|
||||||
|
|
||||||
## DESCRIPTION
|
## Description
|
||||||
|
|
||||||
`irtt-tui` is a live dashboard over the same probing engine as
|
`irtt-tui` gives you a live graph and status dashboard using the same probing
|
||||||
`irtt-client(1)`: a graph and status view instead of a printed event stream.
|
engine as `irtt-client(1)`. You need at least one target to start it.
|
||||||
At least one target is required.
|
|
||||||
|
|
||||||
Target syntax, multiple targets, and pacing are identical to
|
Targets and pacing work the same as in `irtt-client(1)`. See that manual
|
||||||
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
|
for `[LABEL=]TARGET` syntax, multiple targets, and the
|
||||||
`--pacing staggered|burst` option. The full set of negotiation flags
|
`--pacing staggered|burst` option. The configuration flags
|
||||||
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
|
(`--interval`, `--timeouts`, `--length`, `--hmac`, `--clock`, `--tstamp`,
|
||||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
`--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are also shared with
|
||||||
well; consult that manual for their meaning.
|
`irtt-client`, so the same descriptions apply.
|
||||||
|
|
||||||
## CONTINUOUS DEFAULT
|
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
|
||||||
|
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
|
||||||
|
of a hostname its own measurement row and graph series. These switches are
|
||||||
|
mutually exclusive. Explicit IP literals remain single targets; a literal
|
||||||
|
conflicting with `-4` or `-6` is rejected before the dashboard starts.
|
||||||
|
|
||||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
```sh
|
||||||
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
|
irtt-tui -4 uk-cov1.irtt.lochnair.net
|
||||||
a finite run:
|
irtt-tui --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command creates `cov/v4` and/or `cov/v6` according to the system
|
||||||
|
resolver's initial results, letting you compare the two paths directly.
|
||||||
|
See the client manual's address-family section for discovery errors and labels.
|
||||||
|
|
||||||
|
## Continuous default
|
||||||
|
|
||||||
|
The TUI defaults to continuous mode (`--duration 0`) and runs until you
|
||||||
|
quit. If you're used to `irtt-client`, which defaults to a finite run,
|
||||||
|
keep that difference in mind. To set a finite run, pass an explicit
|
||||||
|
`--duration`. Both `--duration` and `--interval` use the
|
||||||
|
[client duration syntax](irtt-client.md#finite-versus-continuous-operation),
|
||||||
|
including fractional values (`2.5s`), composite values (`1m30s`), and units
|
||||||
|
from nanoseconds (`ns`) through hours (`h`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-tui <server> --duration 30s
|
irtt-tui <server> --duration 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
## CONTROLS
|
In continuous mode, the TUI reconnects after a target fails, the peer closes
|
||||||
|
the session, or the session ends because the server limited its duration.
|
||||||
|
After a 1.5-second delay, it groups completed targets into one retry while
|
||||||
|
healthy sessions keep running. Even if every target fails, the TUI stays
|
||||||
|
open until you quit.
|
||||||
|
|
||||||
|
Explicit stops, removed or replaced targets, and no-test completion do not
|
||||||
|
trigger retries. Quitting cancels any pending retry work.
|
||||||
|
|
||||||
|
With `--dual-stack`, a failed family reconnects independently while its healthy
|
||||||
|
sibling continues. Reconnect resolves the original hostname again within the
|
||||||
|
target's family. It does not discover additional families or pin the address
|
||||||
|
returned by initial discovery.
|
||||||
|
|
||||||
|
Reconnecting keeps the target in the same row. Statistics reset when a new
|
||||||
|
session generation is accepted, including attempts that fail before opening.
|
||||||
|
The TUI keeps graph history and the age of the last actual reply, with a
|
||||||
|
gap in the graph between generations.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
The dashboard uses the same layout for any target count and terminal size.
|
||||||
|
It shows:
|
||||||
|
|
||||||
|
- Run status, elapsed time, and finite duration or continuous mode.
|
||||||
|
- The selected target's label and remote address, plus latest signed
|
||||||
|
client-to-server and server-to-client delays (`-` means unavailable).
|
||||||
|
- Target rows with latest effective RTT, cumulative loss percentage, jitter
|
||||||
|
(standard deviation of round-trip IPDV), and age of the last primary reply.
|
||||||
|
- A graph overlaying all targets, with matching target colors and a visible
|
||||||
|
live/history indicator and window size.
|
||||||
|
|
||||||
|
An active session shows `waiting` until it gets a primary reply. Check the
|
||||||
|
reply age to see how fresh the displayed RTT is. The loss estimate comes
|
||||||
|
from the shared statistics collector, so outstanding sends can temporarily
|
||||||
|
count towards cumulative loss. Jitter stays `-` until an IPDV pair is
|
||||||
|
available.
|
||||||
|
|
||||||
|
Use `Tab` / `Shift-Tab` to select a target and bring its row into view.
|
||||||
|
The dashboard shows up to six target rows, with fewer on smaller terminals.
|
||||||
|
Selecting a target changes the identification, one-way summary, and details.
|
||||||
|
The graph still compares every target.
|
||||||
|
|
||||||
|
Press `d` to open details in the graph's space. You'll find the selected
|
||||||
|
target's session and negotiation, packet counters, progress, latest timing
|
||||||
|
sample, full timing statistics, and warnings, along with recent events
|
||||||
|
from all targets.
|
||||||
|
|
||||||
|
Scroll with the arrows or page keys. You can scroll horizontally to read
|
||||||
|
long lines and timing columns, including labels and remote addresses that
|
||||||
|
don't fit in the header. Press `d` or `Esc` to return to the graph with its
|
||||||
|
metric and viewport preserved. You can also use `g` as an alias for `d`.
|
||||||
|
|
||||||
|
You need a terminal at least 56 columns wide and 18 rows tall. Below that
|
||||||
|
size, the TUI asks you to resize while continuing to collect measurements.
|
||||||
|
Resizing and important status redraws still work when the display is paused.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `q`, `Ctrl-C` | Quit |
|
| `q`, `Ctrl-C` | Quit |
|
||||||
| `p` | Toggle display pause (probing continues; only redraws stop) |
|
| `p` | Toggle display pause (probing and acquisition continue) |
|
||||||
| `g` | Toggle Graph / Dashboard view |
|
| `Tab` / `Shift-Tab` | Select next / previous target |
|
||||||
| `m` | Cycle graph metric |
|
| `d`, `g` | Toggle target details |
|
||||||
| `r` | Clear visible graph history |
|
| `Esc` | Close target details |
|
||||||
| `←` / `→` | Pan graph |
|
| `r` | Clear graph history for all targets; keep latest samples and statistics |
|
||||||
| `PageUp` / `PageDown` | Page-pan graph |
|
| `m` | Cycle graph metric (graph visible) |
|
||||||
| `Home` / `End` | Jump to oldest / jump to live |
|
| `←` / `→` | Pan graph; scroll columns in details |
|
||||||
| `+` / `=` | Zoom graph in |
|
| `↑` / `↓` | Scroll details |
|
||||||
| `-` | Zoom graph out |
|
| `PageUp` / `PageDown` | Page-pan graph; page-scroll details |
|
||||||
| `0` | Reset graph window and zoom |
|
| `Home` / `End` | Oldest / live graph; top / bottom of details |
|
||||||
|
| `+` / `=` | Zoom graph in (graph visible) |
|
||||||
|
| `-` | Zoom graph out (graph visible) |
|
||||||
|
| `0` | Reset graph window and zoom, keeping live/history position (graph visible) |
|
||||||
|
|
||||||
## RETAINED HISTORY
|
The six graph metrics are effective RTT, raw RTT, adjusted RTT,
|
||||||
|
client-to-server delay, server-to-client delay, and server processing time.
|
||||||
|
Signed effective/adjusted RTT and one-way values are preserved. Graph windows
|
||||||
|
range from 5 seconds to 24 hours and default to one minute. The footer shows
|
||||||
|
controls appropriate to the currently open graph or details sheet.
|
||||||
|
|
||||||
The TUI keeps its own bounded presentation state on top of the statistics
|
## Retained history
|
||||||
retention described in `irtt-client(1)`:
|
|
||||||
|
|
||||||
- Up to 100,000 graph samples per target.
|
Along with the statistics described in `irtt-client(1)`, the TUI keeps
|
||||||
|
some history for the display:
|
||||||
|
|
||||||
|
- Up to 500,000 graph samples per target.
|
||||||
- Up to 80 recent status/log messages.
|
- Up to 80 recent status/log messages.
|
||||||
|
|
||||||
`r` clears the currently visible graph history and returns the viewport to
|
At 500,000 replies per target, you get approximately 13h53m20s of history
|
||||||
live; it does not change the 100,000-sample cap itself. These are
|
with 100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Each graph
|
||||||
presentation bounds on top of the client's own statistics retention (see
|
sample stores both clocks, six values ready for graphing, and a flag marking
|
||||||
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
|
session boundaries. The latest details keep the full typed timing values
|
||||||
total memory is dominated by whichever of the two is larger for your target
|
and sequence number.
|
||||||
count.
|
|
||||||
|
|
||||||
A **finite** `--duration` TUI run retains exact statistics samples exactly as
|
On 64-bit macOS, each retained sample takes 88 bytes, giving about 42 MiB
|
||||||
`irtt-client(1)` does in finite mode, so its retained state grows with the
|
per target at the cap. Statistics and temporary rendering buffers use
|
||||||
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
|
additional memory. History grows as needed and reserves exactly to the
|
||||||
prints **no** memory warning when that estimate gets large: it owns the
|
cap on its final growth step, so you don't pay for the full history of
|
||||||
alternate screen, and a stderr warning there would be invisible or would
|
every target at startup.
|
||||||
corrupt the display. Size a long finite TUI run from the figures in
|
|
||||||
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
|
|
||||||
|
|
||||||
## EXIT BEHAVIOR
|
Press `r` to clear graph history for all targets and return to the live
|
||||||
|
view. This leaves the 500,000-sample cap unchanged. Display history is kept
|
||||||
|
in addition to the client's statistics (see Measurements and memory in
|
||||||
|
`irtt-client(1)`). During a long continuous TUI session, whichever uses more
|
||||||
|
memory for your target count will dominate the total.
|
||||||
|
|
||||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
If you set a finite `--duration`, the TUI retains exact statistics samples
|
||||||
exit status follows the same peer-close and driver-failure rules as
|
just as `irtt-client(1)` does in finite mode. Memory then grows with the
|
||||||
`irtt-client(1)`.
|
probe count. The TUI does not print the memory warnings you get from
|
||||||
|
`irtt-client`, since a stderr warning on the alternate screen could be
|
||||||
|
invisible or corrupt the display. For a long finite run, plan memory using
|
||||||
|
the figures in `irtt-client(1)` under Measurements and memory, or use the
|
||||||
|
continuous default.
|
||||||
|
|
||||||
## EXAMPLES
|
## Exit behavior
|
||||||
|
|
||||||
|
Quitting with `q` or `Ctrl-C` counts as an interrupted, successful exit.
|
||||||
|
In continuous mode, the TUI reconnects after peer closure and target failures.
|
||||||
|
A managed driver failure or an unexpected reconnect-update failure still
|
||||||
|
ends the run with an error.
|
||||||
|
|
||||||
|
Finite runs do not reconnect. They complete after their target sessions end;
|
||||||
|
a peer closure is a normal session ending, while a run whose targets all fail
|
||||||
|
exits with an error.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-tui host.example
|
irtt-tui host.example
|
||||||
@@ -85,7 +185,7 @@ irtt-tui eu=host-a.example us=host-b.example --pacing burst
|
|||||||
irtt-tui host.example --duration 30s
|
irtt-tui host.example --duration 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
## SEE ALSO
|
## See also
|
||||||
|
|
||||||
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
|
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
|
||||||
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.
|
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.
|
||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user