Compare commits
142
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e32c76c78 | ||
|
|
e65d415c7d | ||
|
|
e7de8bdb30 | ||
|
|
b9cc3e2779 | ||
|
|
b7890f1190 | ||
|
|
bc9ea0fe99 | ||
|
|
2a2dd47e38 | ||
|
|
236b80db1d | ||
|
|
2cd8a7d60d | ||
|
|
784ce77354 | ||
|
|
6ebecaec85 | ||
|
|
664ef56b6e | ||
|
|
5cffd2e56a | ||
|
|
c9e539933b | ||
|
|
a6ba335302 | ||
|
|
eebe83986a | ||
|
|
d467211f41 | ||
|
|
f62e6a9953 | ||
|
|
20960e1b8d | ||
|
|
c436bfb686 | ||
|
|
156db4c720 | ||
|
|
9c703ecff2 | ||
|
|
798efaf360 | ||
|
|
4b05c3027b | ||
|
|
596dd20bcf | ||
|
|
ebc1f2d0b1 | ||
|
|
a45396c236 | ||
|
|
743a050416 | ||
|
|
524d56f72c | ||
|
|
ccdf4ea39c | ||
|
|
1d211c81ad | ||
|
|
ded61b58e2 | ||
|
|
bdc5d152d4 | ||
|
|
8986caecb0 | ||
|
|
edff75e55a | ||
|
|
48a50d7f3a | ||
|
|
74e7532965 | ||
|
|
dc5b19059e | ||
|
|
50fe83a4c8 | ||
|
|
f2fce2f330 | ||
|
|
f350c82dae | ||
|
|
22d3f6cb27 | ||
|
|
85238682b8 | ||
|
|
9240152e4b | ||
|
|
7bbc89e1c0 | ||
|
|
b6043e4961 | ||
|
|
b36842ee98 | ||
|
|
cf48988170 | ||
|
|
41aee2a551 | ||
|
|
722539c8d7 | ||
|
|
8369ec3502 | ||
|
|
1df69969db | ||
|
|
4908921fb8 | ||
|
|
0d69255e9e | ||
|
|
d7d6e76682 | ||
|
|
328ff17901 | ||
|
|
19782dd3fd | ||
|
|
b3241958d9 | ||
|
|
8644b70b89 | ||
|
|
196512068b | ||
|
|
e861de5247 | ||
|
|
d335ad78c9 | ||
|
|
f650b6220a | ||
|
|
865c166828 | ||
|
|
bc2bf34ed4 | ||
|
|
4864357486 | ||
|
|
188d6e5354 | ||
|
|
2882beb2c9 | ||
|
|
ed805e854f | ||
|
|
f2e9c72b22 | ||
|
|
4563816873 | ||
|
|
70a2998f8c | ||
|
|
4ddefb2101 | ||
|
|
04291a0b7b | ||
|
|
c426884fd1 | ||
|
|
b9c2d024d9 | ||
|
|
7ee7474605 | ||
|
|
c18903b097 | ||
|
|
b397916ec6 | ||
|
|
9f6ef340f9 | ||
|
|
3a29b4efb2 | ||
|
|
a2f542bb97 | ||
|
|
5dd478e81a | ||
|
|
1ba4f61072 | ||
|
|
31fd76f78d | ||
|
|
344533f8b6 | ||
|
|
e8b3f64292 | ||
|
|
c98452aa2c | ||
|
|
807d8f2846 | ||
|
|
0cc6b1ea80 | ||
|
|
0ccca9b0ab | ||
|
|
df0d69e35d | ||
|
|
9ab1b78637 | ||
|
|
45351e0ba1 | ||
|
|
e2b70ac50a | ||
|
|
95037f7fa0 | ||
|
|
614579b8b6 | ||
|
|
46d7fdfe30 | ||
|
|
8602f73728 | ||
|
|
5d07853893 | ||
|
|
ccb31a59e7 | ||
|
|
56a2a653d0 | ||
|
|
1ec82d8c56 | ||
|
|
e9e1ff4218 | ||
|
|
2521959364 | ||
|
|
1754b77ea5 | ||
|
|
cab1fd1aab | ||
|
|
c9269ca0ee | ||
|
|
98e7231ede | ||
|
|
a0b51032f2 | ||
|
|
13a907bc28 | ||
|
|
1ce65c0db4 | ||
|
|
90178cfb75 | ||
|
|
4928661467 | ||
|
|
65eb494f7a | ||
|
|
08a9bc4e63 | ||
|
|
3c676edb38 | ||
|
|
e936d8147d | ||
|
|
d51e154783 | ||
|
|
02bad76676 | ||
|
|
0e23017100 | ||
|
|
d71503e4fd | ||
|
|
7bea3dd54e | ||
|
|
7129ee0bc5 | ||
|
|
75aaf68fea | ||
|
|
f470d4d7a6 | ||
|
|
2cdc084ef1 | ||
|
|
5c7d75e39e | ||
|
|
6a4dce68b3 | ||
|
|
0d9826800b | ||
|
|
2dee315a6e | ||
|
|
369e166a6c | ||
|
|
6df16e0205 | ||
|
|
7c190058f4 | ||
|
|
3e2cf3bad7 | ||
|
|
a055e80fd3 | ||
|
|
2634649852 | ||
|
|
b30cf9b3a4 | ||
|
|
5ce5f4fcda | ||
|
|
719073df1c | ||
|
|
9b70bd8b9d | ||
|
|
7b7cb6042e |
No files matched your search
@@ -7,7 +7,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
merge_group:
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -29,10 +28,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
components: rustfmt
|
components: rustfmt
|
||||||
cache-key: fmt
|
cache: false
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
||||||
@@ -49,10 +48,10 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
cache-key: clippy
|
cache: false
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: |
|
run: |
|
||||||
@@ -79,46 +78,22 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- task: check
|
- task: check
|
||||||
runner: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
musl: false
|
musl: false
|
||||||
|
|
||||||
- task: test
|
- task: test
|
||||||
runner: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
musl: false
|
musl: false
|
||||||
|
|
||||||
- task: check
|
- task: check
|
||||||
runner: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-musl
|
target: x86_64-unknown-linux-musl
|
||||||
musl: true
|
musl: true
|
||||||
|
|
||||||
- task: test
|
- task: test
|
||||||
runner: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-musl
|
target: x86_64-unknown-linux-musl
|
||||||
musl: true
|
musl: true
|
||||||
|
|
||||||
- task: check
|
runs-on: ubuntu-latest
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
musl: false
|
|
||||||
|
|
||||||
- task: test
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
musl: false
|
|
||||||
|
|
||||||
- task: check
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
musl: true
|
|
||||||
|
|
||||||
- task: test
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
musl: true
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -134,10 +109,10 @@ jobs:
|
|||||||
sudo apt-get install --no-install-recommends musl-tools
|
sudo apt-get install --no-install-recommends musl-tools
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
cache: false
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} with all features
|
- name: Run ${{ matrix.task }} with all features
|
||||||
run: |
|
run: |
|
||||||
@@ -172,9 +147,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
cache-key: docs
|
cache: false
|
||||||
|
|
||||||
- name: Check docs
|
- name: Check docs
|
||||||
run: |
|
run: |
|
||||||
@@ -183,3 +158,6 @@ jobs:
|
|||||||
--all-features \
|
--all-features \
|
||||||
--no-deps \
|
--no-deps \
|
||||||
--locked
|
--locked
|
||||||
|
|
||||||
|
- name: Run doctests
|
||||||
|
run: cargo test --workspace --doc --all-features --locked
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
name: Publish irtt-server image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
# This repo tags each crate independently (irtt-proto/vX, irtt-client/vX,
|
||||||
|
# ...); only the app package's tag corresponds to this image.
|
||||||
|
- "irtt-rs/v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: irtt-server
|
||||||
|
IMAGE_OWNER: lochnair
|
||||||
|
GITEA_REGISTRY: git.lochnair.net
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
# Pinned so a floating "latest" release doesn't silently change the build.
|
||||||
|
CRANE_VERSION: v0.20.2
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
|
||||||
|
- name: Determine image tags
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
version="${GITHUB_REF_NAME#irtt-rs/v}"
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tags=${version},latest" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
# Manual dispatch off a branch: tag by commit only, never touch
|
||||||
|
# "latest", so an ad-hoc build can't clobber the last release.
|
||||||
|
version="sha-$(git rev-parse --short HEAD)"
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tags=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cross-compilation is handled inside the Dockerfile via tonistiigi/xx
|
||||||
|
# (native builds, no emulation), but buildx still needs a driver that
|
||||||
|
# can assemble a multi-platform manifest list.
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
|
# The automatic per-job token comes back unauthorized for package
|
||||||
|
# writes on this instance, so use a PAT (scope: write:package) instead.
|
||||||
|
# (Secret names can't start with GITEA_/GITHUB_, hence REGISTRY_*.)
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ secrets.GHCR_USERNAME }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build tag list
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
gitea_tags=""
|
||||||
|
for t in "${tag_list[@]}"; do
|
||||||
|
gitea_tags="${gitea_tags}${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t},"
|
||||||
|
done
|
||||||
|
echo "gitea=${gitea_tags%,}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Gitea is the primary publish target: this is the only build. Every
|
||||||
|
# other registry gets an exact digest copy of this same image (see
|
||||||
|
# below), never a separate build, so the hash matches everywhere.
|
||||||
|
- name: Build and push to Gitea
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.tags.outputs.gitea }}
|
||||||
|
|
||||||
|
- name: Install crane
|
||||||
|
run: |
|
||||||
|
mkdir -p "${HOME}/.local/bin"
|
||||||
|
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||||
|
| tar -xz -C "${HOME}/.local/bin" crane
|
||||||
|
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Duplicate image to GHCR (same digest, no rebuild)
|
||||||
|
run: |
|
||||||
|
source_ref="${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
|
||||||
|
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
for t in "${tag_list[@]}"; do
|
||||||
|
crane copy "${source_ref}" "${GHCR_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Fuzz Smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
||||||
|
CARGO_FUZZ_VERSION: 0.13.1
|
||||||
|
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
|
||||||
|
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
|
||||||
|
FUZZ_TARGET: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fuzz-smoke:
|
||||||
|
name: cargo fuzz smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install stable Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install nightly Rust toolchain
|
||||||
|
# Installed alongside, but deliberately not made the directory
|
||||||
|
# default: cargo-fuzz itself is built with the default stable
|
||||||
|
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
||||||
|
# the GNU-host nightly below. Building cargo-fuzz's own dependency
|
||||||
|
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
|
||||||
|
# unstable internal attributes on detecting a nightly compiler.
|
||||||
|
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
|
||||||
|
|
||||||
|
- name: Install cargo-fuzz
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" cargo-fuzz
|
||||||
|
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Build all fuzz targets
|
||||||
|
# libFuzzer sanitizers require dynamically linked GNU binaries; the
|
||||||
|
# runner's default Rust host can be static musl.
|
||||||
|
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
|
||||||
|
working-directory: fuzz
|
||||||
|
|
||||||
|
- name: Run bounded fuzz smoke for every target
|
||||||
|
# A single job building once and running each target sequentially
|
||||||
|
# avoids recompiling the fuzz workspace per target. Ten seconds per
|
||||||
|
# target is enough to catch a broken harness or an obvious
|
||||||
|
# regression; this is not meant to be exhaustive fuzzing.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
|
||||||
|
echo "::group::fuzz smoke: ${target}"
|
||||||
|
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||||
|
-max_total_time=10 \
|
||||||
|
-timeout=5
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
working-directory: fuzz
|
||||||
@@ -30,15 +30,16 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
cache-key: interop-netem
|
cache: false
|
||||||
|
|
||||||
- name: Check host tools
|
- name: Check host tools and network administration access
|
||||||
run: |
|
run: |
|
||||||
ip -V
|
ip -V
|
||||||
tc -V
|
tc -V
|
||||||
go version
|
go version
|
||||||
|
sudo -n true
|
||||||
|
|
||||||
- name: Install upstream irtt
|
- name: Install upstream irtt
|
||||||
run: |
|
run: |
|
||||||
@@ -46,8 +47,8 @@ jobs:
|
|||||||
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
|
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
|
||||||
"$GOBIN/irtt" version
|
"$GOBIN/irtt" version
|
||||||
|
|
||||||
- name: Build irtt-rs CLI
|
- name: Build irtt-rs client
|
||||||
run: cargo build -p irtt-cli --locked
|
run: cargo build -p irtt-rs --bin irtt-client --locked
|
||||||
|
|
||||||
- name: Run netem interop comparisons
|
- name: Run netem interop comparisons
|
||||||
run: |
|
run: |
|
||||||
@@ -55,21 +56,21 @@ jobs:
|
|||||||
--all \
|
--all \
|
||||||
--skip-build \
|
--skip-build \
|
||||||
--upstream-irtt "$GOBIN/irtt" \
|
--upstream-irtt "$GOBIN/irtt" \
|
||||||
--irtt-rs-command "$PWD/target/debug/irtt-cli"
|
--irtt-rs-command "$PWD/target/debug/irtt-client"
|
||||||
|
|
||||||
- name: Append netem interop summary
|
- name: Append netem interop summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
||||||
if [ -n "$latest_summary" ]; then
|
if [ -n "$latest_summary" ]; then
|
||||||
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
|
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
|
echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload netem interop artifacts
|
- name: Upload netem interop artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: https://gitea.com/actions/gitea-upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: interop-netem
|
name: interop-netem
|
||||||
path: target/interop-netem
|
path: target/interop-netem
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Semver
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "irtt-proto/v*.*.*"
|
||||||
|
- "irtt-client/v*.*.*"
|
||||||
|
- "irtt-server/v*.*.*"
|
||||||
|
- "irtt-stats/v*.*.*"
|
||||||
|
- "irtt-rs/v*.*.*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
|
||||||
|
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
semver:
|
||||||
|
name: cargo-semver-checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# The CLI preserves the previous wrapper's `feature-group: all-features`
|
||||||
|
# behavior without its GitHub API/cache dependency.
|
||||||
|
- name: Install cargo-semver-checks
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" cargo-semver-checks
|
||||||
|
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Check semver compatibility
|
||||||
|
run: cargo semver-checks check-release --all-features
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
name: Supply chain
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: "41 2 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ACTIONLINT_VERSION: 1.7.7
|
||||||
|
CARGO_DENY_VERSION: 0.20.2
|
||||||
|
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
|
||||||
|
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cargo-deny:
|
||||||
|
name: cargo-deny ${{ matrix.checks }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Advisories can appear after code was merged. Keep this visible,
|
||||||
|
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||||
|
- checks: advisories
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# These are policy choices in this repo. They should block PRs.
|
||||||
|
- checks: bans licenses sources
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
continue-on-error: ${{ matrix.continue-on-error }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install cargo-deny
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" --strip-components=1 \
|
||||||
|
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
|
||||||
|
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run cargo-deny
|
||||||
|
run: >-
|
||||||
|
cargo deny --manifest-path Cargo.toml --all-features check
|
||||||
|
${{ matrix.checks }}
|
||||||
|
|
||||||
|
actionlint:
|
||||||
|
name: actionlint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install actionlint
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" actionlint
|
||||||
|
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: actionlint handwritten workflows
|
||||||
|
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
|
||||||
|
# required absolute action URL in interop-netem.yml, so that workflow
|
||||||
|
# is checked by the Gitea runner instead.
|
||||||
|
run: |
|
||||||
|
actionlint \
|
||||||
|
.gitea/workflows/ci.yml \
|
||||||
|
.gitea/workflows/docker-publish.yml \
|
||||||
|
.gitea/workflows/fuzz.yml \
|
||||||
|
.gitea/workflows/semver.yml \
|
||||||
|
.gitea/workflows/supply-chain.yml \
|
||||||
|
.github/workflows/actions-security.yml \
|
||||||
|
.github/workflows/ci-arm.yml \
|
||||||
|
.github/workflows/osv-scanner.yml \
|
||||||
|
.github/workflows/rust-clippy.yml
|
||||||
|
|
||||||
|
- name: actionlint generated release workflow
|
||||||
|
run: >-
|
||||||
|
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
|
||||||
@@ -16,34 +16,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
actionlint:
|
|
||||||
name: actionlint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: actionlint handwritten workflows
|
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
|
||||||
with:
|
|
||||||
files: >-
|
|
||||||
.github/workflows/actions-security.yml,
|
|
||||||
.github/workflows/ci.yml,
|
|
||||||
.github/workflows/interop-netem.yml,
|
|
||||||
.github/workflows/osv-scanner.yml,
|
|
||||||
.github/workflows/rust-clippy.yml,
|
|
||||||
.github/workflows/semver.yml,
|
|
||||||
.github/workflows/supply-chain.yml
|
|
||||||
|
|
||||||
- name: actionlint generated release workflow
|
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
|
||||||
with:
|
|
||||||
files: .github/workflows/irtt-cli-release.yml
|
|
||||||
shellcheck: false
|
|
||||||
zizmor:
|
zizmor:
|
||||||
name: zizmor
|
name: zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -60,7 +32,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
|
||||||
|
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||||
|
# both workflow directories through its GitHub Actions rule set.
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
persona: regular
|
persona: regular
|
||||||
|
inputs: |
|
||||||
|
.github/workflows
|
||||||
|
.gitea/workflows
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Rust ARM CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
merge_group:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: cargo ${{ matrix.task }} (${{ matrix.target }})
|
||||||
|
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- task: check
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
musl: false
|
||||||
|
|
||||||
|
- task: test
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
musl: false
|
||||||
|
|
||||||
|
- task: check
|
||||||
|
target: aarch64-unknown-linux-musl
|
||||||
|
musl: true
|
||||||
|
|
||||||
|
- task: test
|
||||||
|
target: aarch64-unknown-linux-musl
|
||||||
|
musl: true
|
||||||
|
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install musl toolchain
|
||||||
|
if: matrix.musl
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends musl-tools
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
|
with:
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.task }} with all features
|
||||||
|
run: |
|
||||||
|
cargo ${{ matrix.task }} \
|
||||||
|
--workspace \
|
||||||
|
--all-targets \
|
||||||
|
--all-features \
|
||||||
|
--target ${{ matrix.target }} \
|
||||||
|
--locked
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.task }} without default features
|
||||||
|
run: |
|
||||||
|
cargo ${{ matrix.task }} \
|
||||||
|
--workspace \
|
||||||
|
--all-targets \
|
||||||
|
--no-default-features \
|
||||||
|
--target ${{ matrix.target }} \
|
||||||
|
--locked
|
||||||
@@ -42,7 +42,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'irtt-cli**[0-9]+.[0-9]+.[0-9]+*'
|
- 'irtt-rs**[0-9]+.[0-9]+.[0-9]+*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||||
@@ -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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.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@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||||
with:
|
|
||||||
# Example of specifying custom arguments
|
|
||||||
scan-args: |-
|
|
||||||
-r
|
|
||||||
--skip-git
|
|
||||||
./
|
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
cache-key: clippy-sarif
|
cache-key: clippy-sarif
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
- name: Upload Clippy SARIF
|
- name: Upload Clippy SARIF
|
||||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: rust-clippy-results.sarif
|
sarif_file: rust-clippy-results.sarif
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Semver
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "irtt-proto/v*.*.*"
|
|
||||||
- "irtt-client/v*.*.*"
|
|
||||||
- "irtt-server/v*.*.*"
|
|
||||||
- "irtt-stats/v*.*.*"
|
|
||||||
- "irtt-cli/v*.*.*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
semver:
|
|
||||||
name: cargo-semver-checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check semver compatibility
|
|
||||||
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2
|
|
||||||
with:
|
|
||||||
feature-group: all-features
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
name: Supply chain
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
merge_group:
|
|
||||||
schedule:
|
|
||||||
- cron: "41 2 * * 1"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cargo-deny:
|
|
||||||
name: cargo-deny ${{ matrix.checks }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# Advisories can appear after code was merged. Keep this visible,
|
|
||||||
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
|
||||||
- checks: advisories
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# These are policy choices in this repo. They should block PRs.
|
|
||||||
- checks: bans licenses sources
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
continue-on-error: ${{ matrix.continue-on-error }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run cargo-deny
|
|
||||||
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2
|
|
||||||
with:
|
|
||||||
command: check
|
|
||||||
arguments: --all-features
|
|
||||||
command-arguments: ${{ matrix.checks }}
|
|
||||||
|
|
||||||
actionlint:
|
|
||||||
name: actionlint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: actionlint handwritten workflows
|
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
|
||||||
with:
|
|
||||||
files: >-
|
|
||||||
.github/workflows/actions-security.yml,
|
|
||||||
.github/workflows/ci.yml,
|
|
||||||
.github/workflows/interop-netem.yml,
|
|
||||||
.github/workflows/osv-scanner.yml,
|
|
||||||
.github/workflows/rust-clippy.yml,
|
|
||||||
.github/workflows/semver.yml,
|
|
||||||
.github/workflows/supply-chain.yml
|
|
||||||
|
|
||||||
- name: actionlint generated release workflow
|
|
||||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
|
||||||
with:
|
|
||||||
files: .github/workflows/irtt-cli-release.yml
|
|
||||||
shellcheck: false
|
|
||||||
@@ -41,7 +41,7 @@ For compatibility questions, the verified spec and black-box observations define
|
|||||||
|
|
||||||
Main boundary:
|
Main boundary:
|
||||||
|
|
||||||
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
|
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-rs` (application package, `crates/irtt-app`) — presentation/orchestration.
|
||||||
|
|
||||||
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
|
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
|
||||||
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
|
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
|
||||||
@@ -51,7 +51,7 @@ Main boundary:
|
|||||||
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
||||||
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
||||||
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
||||||
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
|
- `irtt-rs` (application package, `crates/irtt-app`): argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Produces the `irtt-rs` multicall dispatcher plus the dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Do not duplicate protocol/session state machines here.
|
||||||
|
|
||||||
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
|
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
|||||||
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
||||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||||
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
|
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
|
||||||
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
|
- The server applet exists behind the `irtt-rs` application package's `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
|
||||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
||||||
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||||
|
|
||||||
@@ -82,42 +82,49 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
|||||||
|
|
||||||
## Testing policy
|
## Testing policy
|
||||||
|
|
||||||
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage.
|
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage. Tests are maintained repository code: every persistent test adds review, CI, refactoring, and maintenance cost, so it must justify that cost.
|
||||||
|
|
||||||
### Prefer behavior over internals
|
### Test at the owning layer
|
||||||
|
|
||||||
For client, managed-runtime, blocking, CLI, and TUI behavior, prefer normal-interface component/integration tests: real UDP/session exchange, emitted events/outcomes, negotiated params, multi-target lifecycle, process exit status, and user-visible diagnostics.
|
Prefer production interfaces and component/integration tests at the lowest architectural layer that owns the complete behavior. Higher-level or process-level is not inherently better.
|
||||||
|
|
||||||
One strong behavioral test is usually better than several tests of private helpers that reconstruct the same behavior.
|
- `irtt-proto`: wire formats, codecs, validation, malformed input, and protocol boundaries.
|
||||||
|
- `irtt-client`: client/session behavior, multi-target orchestration, burst/staggered pacing, scheduling, lifecycle, managed events, and target updates.
|
||||||
|
- `irtt-server`: server/session behavior and server runtime/orchestration.
|
||||||
|
- `irtt-stats`: statistics, loss/IPDV accounting, rolling windows, and retention bounds.
|
||||||
|
- `irtt-rs` / `irtt-app`: behavior introduced by the application layer itself, such as argument-to-config mapping, presentation/formatting, app-specific diagnostics/exit policy, and genuine process/signal behavior.
|
||||||
|
|
||||||
Do not add persistent tests merely because implementation code changed. Avoid tests that primarily:
|
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
|
||||||
|
|
||||||
- assert private fields, cursors, queue shapes, or exact internal representation;
|
Concurrency correctness is a production requirement, but deterministic coverage
|
||||||
- mirror implementation steps line-for-line;
|
does not by itself justify a test-only synchronization hook. Prefer simplifying
|
||||||
- exercise branches/helpers solely for coverage;
|
the concurrency machinery or observing receipts/status/events through the public
|
||||||
|
control surface before introducing or restoring internal race gates.
|
||||||
|
|
||||||
|
### Every persistent test must earn its place
|
||||||
|
|
||||||
|
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
|
||||||
|
|
||||||
|
One strong behavioral test through a normal production interface is usually better than several tests of private helpers or reconstructed internals. Be especially skeptical of unit tests that depend on private functions, methods, structs, fields, or implementation sequence: harmless internal refactors should not require test rewrites.
|
||||||
|
|
||||||
|
Avoid tests that primarily:
|
||||||
|
|
||||||
|
- assert private representation or historical implementation details;
|
||||||
|
- mirror implementation steps or exercise branches solely for coverage;
|
||||||
- duplicate stronger coverage at another layer;
|
- duplicate stronger coverage at another layer;
|
||||||
- pin incidental scheduling/ordering that is not contractual;
|
- pin exact human-readable wording unless that wording is itself contractual;
|
||||||
- test standard-library/dependency behavior;
|
- test standard-library/dependency behavior;
|
||||||
- require production test-only observability for a fact naturally visible through packets/events/outcomes.
|
- use subprocess machinery or test-only observability when the owning crate exposes the behavior directly.
|
||||||
|
|
||||||
A harmless private refactor should not require widespread test rewrites unless a deliberately protected invariant changed.
|
If a component/integration test through the owning crate's normal API protects the same durable behavior, prefer it unless a focused unit test materially improves precision, diagnosis, or coverage of a hard-to-observe invariant.
|
||||||
|
|
||||||
### Unit tests where they earn it
|
### Unit tests where they earn it
|
||||||
|
|
||||||
Focused unit/table-driven tests are encouraged for stable pure or algorithmic behavior, especially:
|
Focused unit/table-driven tests are appropriate for stable pure or algorithmic behavior, especially protocol codecs/vectors, statistics formulas, hand-rolled data structures, and resource/liveness invariants that cannot be observed reliably from a higher-level production interface.
|
||||||
|
|
||||||
- `irtt-proto` layouts, codecs, vectors, HMAC, parsing, malformed input, and boundaries;
|
|
||||||
- `irtt-stats` formulas, loss accounting, IPDV, rolling windows, and bounded state;
|
|
||||||
- hand-rolled data structures and subtle pure invariants;
|
|
||||||
- resource/liveness guarantees that cannot be observed reliably from a higher layer.
|
|
||||||
|
|
||||||
If a unit test and a normal-interface test prove the same durable behavior, prefer the normal-interface test unless the unit test materially improves precision or diagnosis.
|
|
||||||
|
|
||||||
### Temporary implementation tests
|
### Temporary implementation tests
|
||||||
|
|
||||||
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition.
|
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition. Remove or consolidate them before finalizing unless they independently protect a durable regression or invariant.
|
||||||
|
|
||||||
Before finalizing, remove or consolidate them unless they independently protect a durable regression or invariant. A test being useful during development does not by itself justify permanent repository code.
|
|
||||||
|
|
||||||
### Resource/liveness tests and hooks
|
### Resource/liveness tests and hooks
|
||||||
|
|
||||||
@@ -171,6 +178,7 @@ Use the narrowest useful checks first. For substantive workspace changes, final
|
|||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo check --workspace --all-targets --all-features --locked
|
cargo check --workspace --all-targets --all-features --locked
|
||||||
cargo test --workspace --all-targets --all-features --locked
|
cargo test --workspace --all-targets --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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -192,7 +200,7 @@ Report concisely:
|
|||||||
|
|
||||||
1. what changed;
|
1. what changed;
|
||||||
2. verification actually run and its result;
|
2. verification actually run and its result;
|
||||||
3. meaningful tests added/updated/consolidated/removed;
|
3. meaningful tests added/updated/consolidated/removed, with justification for each persistent test added or materially expanded;
|
||||||
4. remaining risks, ambiguities, or follow-ups.
|
4. remaining risks, ambiguities, or follow-ups.
|
||||||
|
|
||||||
Do not produce a giant changelog of incidental edits.
|
Do not produce a giant changelog of incidental edits.
|
||||||
+102
-3
@@ -10,6 +10,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-proto
|
## irtt-proto
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -28,6 +34,24 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-client
|
## irtt-client
|
||||||
|
|
||||||
|
### 0.5.3
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
|
||||||
|
|
||||||
|
### 0.5.2
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -50,6 +74,24 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-server
|
## irtt-server
|
||||||
|
|
||||||
|
### 0.5.3
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
|
||||||
|
|
||||||
|
### 0.5.2
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- New public `address_family_available()` helper (re-exported from `set`), for probing whether an address family can actually bind locally rather than merely constructing a socket for it. Used by the `irtt-rs` server applet to distinguish a genuinely unsupported address family from an ordinary bind failure when falling back from its default dual-family bind.
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -77,6 +119,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-stats
|
## irtt-stats
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
@@ -88,6 +136,55 @@ release. The format is loosely based on
|
|||||||
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
|
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
|
||||||
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
|
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
|
||||||
|
|
||||||
|
## irtt-rs
|
||||||
|
|
||||||
|
### 0.7.1
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
|
||||||
|
|
||||||
|
### 0.7.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
|
||||||
|
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
|
||||||
|
|
||||||
|
### 0.6.2
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
|
||||||
|
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
|
||||||
|
|
||||||
|
### 0.6.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- The `irtt-server` applet now has sensible zero-argument defaults: with no `--bind`, it binds the wildcard IRTT port on both address families (`[::]:2112` then `0.0.0.0:2112`) instead of requiring an explicit address, on platforms where wildcard reply-source selection is supported. Any explicit `--bind` fully replaces the default pair rather than adding to it.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- If one of the two default listeners fails because its address family has no local support on the host (e.g. IPv6 administratively disabled on an otherwise IPv4-capable host), the server now falls back to serving just the surviving family instead of failing outright. Any other failure reason (port in use, permission denied, no safe wildcard reply-source path) still fails startup as it would for an explicit `--bind`.
|
||||||
|
|
||||||
|
### 0.6.0
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- The application Cargo package was renamed from `irtt-cli` to `irtt-rs`, moving to `crates/irtt-app`. This is the application distribution package that produces the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries; it is unrelated to the reusable `irtt-client` library crate, which keeps its existing name and version.
|
||||||
|
- The `irtt-cli` executable was hard-renamed to `irtt-client` to avoid reading as an umbrella CLI now that the application also ships a first-class server. There is no `irtt-cli` compatibility binary or alias.
|
||||||
|
- Dedicated binaries now have role-specific entry points (`irtt-client`, `irtt-tui`, `irtt-server`) instead of all funneling through the multicall dispatcher's argv0/subcommand logic. Each dedicated binary always runs its own role regardless of the name it is invoked or copied under, and no longer links code for the other applet roles it doesn't implement. The `irtt-rs` binary remains the feature-selectable multicall dispatcher and is the only binary that inspects argv0 or subcommands.
|
||||||
|
- Future application releases move to the `irtt-rs/vX.Y.Z` tag namespace (previously `irtt-cli/vX.Y.Z`); see [Releasing](#releasing).
|
||||||
|
|
||||||
## irtt-cli
|
## irtt-cli
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
@@ -125,6 +222,8 @@ rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
|
|||||||
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
|
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); 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. `irtt-cli` is the only crate with prebuilt binary artifacts:
|
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
|
||||||
pushing an `irtt-cli/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
|
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
|
||||||
to build and publish a GitHub Release with platform binaries.
|
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
|
||||||
|
to build and publish a GitHub Release with platform binaries. The historical
|
||||||
|
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
|
||||||
Generated
+301
-127
@@ -109,7 +109,16 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec 0.6.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -118,6 +127,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -126,9 +141,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -186,9 +201,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
@@ -198,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.6"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -208,9 +223,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.6"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -220,21 +235,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.4"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmov"
|
name = "cmov"
|
||||||
@@ -298,7 +313,7 @@ version = "0.29.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"document-features",
|
"document-features",
|
||||||
@@ -370,9 +385,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.24.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
|
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"darling_macro",
|
"darling_macro",
|
||||||
@@ -380,26 +395,26 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_core"
|
name = "darling_core"
|
||||||
version = "0.24.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
|
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.24.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
|
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -464,7 +479,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -481,9 +496,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.17.0"
|
version = "1.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
@@ -516,10 +531,16 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set 0.5.3",
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -533,9 +554,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "finl_unicode"
|
name = "finl_unicode"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixedbitset"
|
name = "fixedbitset"
|
||||||
@@ -657,9 +678,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
@@ -681,41 +702,26 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "instability"
|
name = "instability"
|
||||||
version = "0.3.13"
|
version = "0.3.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
|
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"indoc",
|
"indoc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "irtt-cli"
|
|
||||||
version = "0.5.0"
|
|
||||||
dependencies = [
|
|
||||||
"clap",
|
|
||||||
"crossterm",
|
|
||||||
"ctrlc",
|
|
||||||
"irtt-client",
|
|
||||||
"irtt-proto",
|
|
||||||
"irtt-server",
|
|
||||||
"irtt-stats",
|
|
||||||
"ratatui",
|
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.0"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -731,31 +737,49 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-proto"
|
name = "irtt-proto"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac",
|
||||||
"md-5",
|
"md-5",
|
||||||
|
"proptest",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "irtt-rs"
|
||||||
|
version = "0.7.1"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"crossterm",
|
||||||
|
"ctrlc",
|
||||||
|
"irtt-client",
|
||||||
|
"irtt-proto",
|
||||||
|
"irtt-server",
|
||||||
|
"irtt-stats",
|
||||||
|
"ratatui",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-server"
|
name = "irtt-server"
|
||||||
version = "0.5.0"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
|
"measurement-stats",
|
||||||
|
"proptest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -781,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -798,7 +822,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -809,15 +833,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.190"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
@@ -831,7 +855,7 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -857,27 +881,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.2"
|
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 = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac_address"
|
name = "mac_address"
|
||||||
version = "1.1.8"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix 0.29.0",
|
"nix 0.30.1",
|
||||||
"winapi",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -890,6 +914,12 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "measurement-stats"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0edbfa45a060f44b15568637075ecebbe258c22f86ce6f2b5d58d7a64cbdb689"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
@@ -919,9 +949,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
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 = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -935,7 +965,19 @@ version = "0.29.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -948,7 +990,7 @@ version = "0.31.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1094,9 +1136,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest"
|
name = "pest"
|
||||||
version = "2.8.8"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
|
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"ucd-trie",
|
"ucd-trie",
|
||||||
@@ -1104,9 +1146,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_derive"
|
name = "pest_derive"
|
||||||
version = "2.8.8"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
|
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_generator",
|
"pest_generator",
|
||||||
@@ -1114,9 +1156,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_generator"
|
name = "pest_generator"
|
||||||
version = "2.8.8"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
|
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_meta",
|
"pest_meta",
|
||||||
@@ -1127,9 +1169,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_meta"
|
name = "pest_meta"
|
||||||
version = "2.8.8"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
|
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
]
|
]
|
||||||
@@ -1161,7 +1203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1204,6 +1246,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -1213,6 +1264,31 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proptest"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||||
|
dependencies = [
|
||||||
|
"bit-set 0.8.0",
|
||||||
|
"bit-vec 0.8.0",
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_xorshift",
|
||||||
|
"regex-syntax",
|
||||||
|
"rusty-fork",
|
||||||
|
"tempfile",
|
||||||
|
"unarray",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -1236,11 +1312,31 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.7"
|
version = "0.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1249,6 +1345,24 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_xorshift"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.30.2"
|
version = "0.30.2"
|
||||||
@@ -1271,7 +1385,7 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
"critical-section",
|
"critical-section",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
@@ -1281,7 +1395,7 @@ dependencies = [
|
|||||||
"palette",
|
"palette",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-truncate",
|
"unicode-truncate",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
@@ -1336,7 +1450,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"indoc",
|
"indoc",
|
||||||
"instability",
|
"instability",
|
||||||
@@ -1356,7 +1470,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1399,11 +1513,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -1416,6 +1530,18 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusty-fork"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"quick-error",
|
||||||
|
"tempfile",
|
||||||
|
"wait-timeout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1461,7 +1587,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1508,9 +1634,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
@@ -1520,9 +1646,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.2"
|
version = "1.16.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
@@ -1597,22 +1723,35 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.3"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termina"
|
name = "termina"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
@@ -1648,7 +1787,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
"finl_unicode",
|
"finl_unicode",
|
||||||
@@ -1693,11 +1832,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.20",
|
"thiserror-impl 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1713,13 +1852,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1745,9 +1884,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
@@ -1765,7 +1904,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1781,10 +1920,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unarray"
|
||||||
version = "1.0.24"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
@@ -1817,9 +1962,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic",
|
"atomic",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -1842,6 +1987,15 @@ dependencies = [
|
|||||||
"utf8parse",
|
"utf8parse",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wait-timeout"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -1859,9 +2013,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.127"
|
version = "0.2.129"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -1872,9 +2026,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.127"
|
version = "0.2.129"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -1882,22 +2036,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.127"
|
version = "0.2.129"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.127"
|
version = "0.2.129"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -2016,3 +2170,23 @@ 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",
|
||||||
|
]
|
||||||
+3
-6
@@ -4,7 +4,7 @@ members = [
|
|||||||
"crates/irtt-client",
|
"crates/irtt-client",
|
||||||
"crates/irtt-server",
|
"crates/irtt-server",
|
||||||
"crates/irtt-stats",
|
"crates/irtt-stats",
|
||||||
"crates/irtt-cli",
|
"crates/irtt-app",
|
||||||
"crates/irtt-integration-tests",
|
"crates/irtt-integration-tests",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -15,22 +15,19 @@ 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"
|
homepage = "https://github.com/Lochnair/irtt-rs"
|
||||||
readme = "README.md"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||||
irtt-client = { version = "0.5.0", path = "crates/irtt-client" }
|
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||||
irtt-server = { version = "0.5.0", path = "crates/irtt-server" }
|
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
# The profile that 'dist' will build with
|
# The profile that 'dist' will build with
|
||||||
[profile.dist]
|
[profile.dist]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
lto = "thin"
|
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Builds the irtt-server applet only, statically linked against musl, and
|
||||||
|
# ships it in a scratch image with no shell or libc.
|
||||||
|
#
|
||||||
|
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
|
||||||
|
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
|
||||||
|
# picks the right musl target triple, sysroot, and linker for whatever
|
||||||
|
# platform buildx asked for.
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
|
||||||
|
|
||||||
|
COPY --from=xx / /
|
||||||
|
|
||||||
|
RUN apk add --no-cache clang lld file
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
RUN xx-apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
|
||||||
|
RUN xx-cargo build \
|
||||||
|
--release \
|
||||||
|
--locked \
|
||||||
|
-p irtt-rs \
|
||||||
|
--bin irtt-server \
|
||||||
|
--no-default-features \
|
||||||
|
--features server \
|
||||||
|
--target-dir /build/target \
|
||||||
|
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
|
||||||
|
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=builder /irtt-server /irtt-server
|
||||||
|
|
||||||
|
ENTRYPOINT ["/irtt-server"]
|
||||||
@@ -1,361 +1,180 @@
|
|||||||
# irtt-rs
|
# irtt-rs
|
||||||
|
|
||||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server.
|
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
|
||||||
|
finite or continuous UDP latency probing, plus the server side those probes
|
||||||
|
run against.
|
||||||
|
|
||||||
It provides command-line applets and reusable Rust libraries for finite or continuous latency probing, plus the server side those probes can run against. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.
|
This is not the upstream IRTT project. For the original implementation,
|
||||||
|
protocol background, and broader documentation, see
|
||||||
This is not the upstream IRTT project. For the original implementation, protocol background, and broader documentation, see [heistp/irtt](https://github.com/heistp/irtt).
|
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
|
||||||
|
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
|
||||||
|
mistaken for it.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Finite and continuous probe sessions
|
* Finite and continuous probe sessions
|
||||||
* Concurrent probing of multiple targets
|
* Concurrent probing of multiple targets, with staggered or burst pacing
|
||||||
* Reusable client library with a structured event stream
|
* Reusable client library with a structured event stream (blocking, Tokio,
|
||||||
* Human-readable table output
|
or managed)
|
||||||
* CSV, TSV, and JSON Lines output for scripts and monitoring systems
|
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
|
||||||
* Selectable output columns
|
monitoring systems, with selectable output columns
|
||||||
* Automatic final summaries for eligible table-output runs
|
|
||||||
* Optional terminal UI
|
* Optional terminal UI
|
||||||
* Reusable Tokio-native server library
|
* Reusable Tokio-native server library
|
||||||
* UDP server applet with explicit bind and session policy options
|
* UDP server applet with sensible zero-argument defaults and explicit bind
|
||||||
|
and session-policy options
|
||||||
* Router-friendly multi-applet binary layout
|
* Router-friendly multi-applet binary layout
|
||||||
|
|
||||||
The installed dispatcher is named `irtt-rs` rather than `irtt` to avoid conflicting with or being mistaken for upstream IRTT.
|
## Comparison with upstream IRTT
|
||||||
|
|
||||||
|
`irtt-rs` is an independent, compatible reimplementation, not a successor or
|
||||||
|
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
|
||||||
|
remains the original implementation. Interoperability is a project goal; see
|
||||||
|
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
|
||||||
|
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
|
||||||
|
methodology and protocol baseline this project verifies against. Choose
|
||||||
|
between them based on the capabilities and deployment model you need.
|
||||||
|
|
||||||
|
The table below was checked against upstream release
|
||||||
|
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
|
||||||
|
This project's own interop testing and protocol baseline are currently
|
||||||
|
pinned to the prior release, v0.9.1; the wire protocol version number is
|
||||||
|
unchanged between the two, but v0.9.2 has not itself been exercised against
|
||||||
|
this project's interop harness.
|
||||||
|
|
||||||
|
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
|
||||||
|
| Finite probing | Supported, default 10s | Supported, default 1m |
|
||||||
|
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
|
||||||
|
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
|
||||||
|
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
|
||||||
|
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
|
||||||
|
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
|
||||||
|
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
|
||||||
|
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
|
||||||
|
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
|
||||||
|
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
|
||||||
|
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
|
||||||
|
|
||||||
|
Upstream has capabilities this project does not implement: a `report`
|
||||||
|
subcommand for replaying saved JSON results, benchmarking and
|
||||||
|
timer/clock-diagnostic subcommands, client-side payload fill and
|
||||||
|
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
|
||||||
|
server-side fill policy (this project's server always honors a session's
|
||||||
|
negotiated fill request, with a fixed fallback pattern, but has no equivalent
|
||||||
|
to upstream's flags for restricting or overriding it), syslog logging, and
|
||||||
|
bundled systemd/OpenRC service files.
|
||||||
|
|
||||||
|
This project has capabilities upstream does not implement: multi-target
|
||||||
|
probing with labels, the terminal UI, CSV/TSV output with selectable
|
||||||
|
columns, a quantified and actively-warned memory-retention model for
|
||||||
|
long-running client sessions, and best-effort Linux kernel-timestamp
|
||||||
|
preference for its own send/receive endpoint.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The build requires Rust 1.88 or newer.
|
The build requires Rust 1.88 or newer.
|
||||||
|
|
||||||
From a local checkout:
|
Install the latest release from crates.io:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install irtt-rs --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
|
||||||
|
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
|
||||||
|
isolation with `--no-default-features` plus the applet's own feature, for
|
||||||
|
example a lightweight client-only install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install irtt-rs --locked --no-default-features --features client
|
||||||
|
```
|
||||||
|
|
||||||
|
For development, or to install directly from a checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/Lochnair/irtt-rs.git
|
git clone https://github.com/Lochnair/irtt-rs.git
|
||||||
cd irtt-rs
|
cd irtt-rs
|
||||||
cargo install --path crates/irtt-cli
|
cargo install --path crates/irtt-app --locked
|
||||||
```
|
|
||||||
|
|
||||||
This installs:
|
|
||||||
|
|
||||||
* `irtt-rs`, the canonical multi-applet dispatcher
|
|
||||||
* `irtt-cli`, the stream and text client
|
|
||||||
* `irtt-server`, the UDP server
|
|
||||||
|
|
||||||
To also install the optional terminal UI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo install --path crates/irtt-cli --features tui
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Probe a server using the default settings:
|
Probe a server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli netperf-eu.bufferbloat.net:2112
|
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the test duration and probe interval:
|
Probe continuously until interrupted (`Ctrl-C`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli netperf-eu.bufferbloat.net:2112 \
|
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
||||||
--duration 30s \
|
|
||||||
--interval 100ms
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run continuously until interrupted:
|
Watch the same session live in the terminal UI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli netperf-eu.bufferbloat.net:2112 --duration 0
|
irtt-tui netperf-eu.bufferbloat.net:2112
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `Ctrl-C` to stop gracefully.
|
Run a server with sensible defaults — no `--bind` needed:
|
||||||
|
|
||||||
## Multiple targets
|
|
||||||
|
|
||||||
Probe several targets concurrently:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-cli host-a:2112 host-b:2112
|
irtt-server
|
||||||
```
|
```
|
||||||
|
|
||||||
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix assigns the logical
|
## Documentation
|
||||||
target name used in output and the TUI; without it, the target string itself is used. Assign
|
|
||||||
stable labels to targets:
|
|
||||||
|
|
||||||
```sh
|
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
|
||||||
irtt-cli ams=ams.example.com:2112 sg=sg.example.com:2112
|
formats and columns, measurement field definitions, finite/continuous
|
||||||
```
|
memory behavior, kernel timestamps.
|
||||||
|
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
|
||||||
|
retained history.
|
||||||
|
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
|
||||||
|
overrides, wildcard listener behavior, session policy, HMAC.
|
||||||
|
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
|
||||||
|
binary names, feature-gated builds.
|
||||||
|
* [docs.rs](https://docs.rs) for the library crates below.
|
||||||
|
* [`docs/protocol/`](docs/protocol/) and
|
||||||
|
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
|
||||||
|
protocol specification and interoperability validation against upstream
|
||||||
|
`irtt`.
|
||||||
|
|
||||||
Labeled and unlabeled targets can be freely mixed in one argument list:
|
Every applet also documents its full option list with `--help`.
|
||||||
|
|
||||||
```sh
|
## Crates
|
||||||
irtt-cli host-a:2112 ams=ams.example.com:2112
|
|
||||||
```
|
|
||||||
|
|
||||||
Multi-target sessions use staggered pacing by default. To send each target's probes together:
|
| Crate | crates.io | What it is |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
|
||||||
|
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
|
||||||
|
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
|
||||||
|
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
|
||||||
|
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
|
||||||
|
|
||||||
```sh
|
Each library crate has its own crates.io README describing what it's for and
|
||||||
irtt-cli host-a:2112 host-b:2112 --pacing burst
|
when to use it instead of `irtt-client`/`irtt-server` directly.
|
||||||
```
|
|
||||||
|
|
||||||
Target labels are included in the default multi-target output.
|
|
||||||
|
|
||||||
## Output formats
|
|
||||||
|
|
||||||
The CLI supports four event-row formats:
|
|
||||||
|
|
||||||
* `table`: human-readable terminal output
|
|
||||||
* `csv`: comma-separated output
|
|
||||||
* `tsv`: tab-separated output
|
|
||||||
* `jsonl`: one JSON object per event
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-cli <server> --format table
|
|
||||||
irtt-cli <server> --format jsonl
|
|
||||||
irtt-cli <server> --format csv \
|
|
||||||
--columns event,seq,remote,effective_rtt_us
|
|
||||||
```
|
|
||||||
|
|
||||||
For a stream containing only effective RTT values in microseconds:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-cli <server> \
|
|
||||||
--format tsv \
|
|
||||||
--columns effective_rtt_us \
|
|
||||||
--header never
|
|
||||||
```
|
|
||||||
|
|
||||||
List all available columns with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-cli --list-columns
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful measurement fields include:
|
|
||||||
|
|
||||||
* `raw_rtt_us`: client-observed send-to-receive RTT
|
|
||||||
* `adjusted_rtt_us`: RTT adjusted for server processing when available
|
|
||||||
* `effective_rtt_us`: adjusted RTT when available, otherwise raw RTT
|
|
||||||
* `sd_us` and `rd_us`: send and receive one-way delay estimates
|
|
||||||
* `ipdv_us`: inter-packet delay variation
|
|
||||||
* `server_processing_us`: time spent processing the packet at the server
|
|
||||||
|
|
||||||
Adjusted RTT can be negative when server processing exceeds the measured raw RTT. One-way delay estimates can be negative because of clock skew between the client and server.
|
|
||||||
|
|
||||||
Default table output prints a final summary after completed finite runs and interrupted continuous runs when the run policy permits it. CSV, TSV, and JSON Lines output do not print this summary.
|
|
||||||
|
|
||||||
A peer closing a finite CLI or TUI run is accepted as a terminal outcome. In
|
|
||||||
continuous mode, peer closure exits nonzero unless the user requested
|
|
||||||
interruption, allowing a supervisor to restart the client.
|
|
||||||
|
|
||||||
## Terminal UI
|
|
||||||
|
|
||||||
When built with the `tui` feature, `irtt-tui` provides a live cumulative dashboard:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-tui <server>
|
|
||||||
```
|
|
||||||
|
|
||||||
It runs continuously by default. A finite duration can be selected explicitly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-tui <server> --duration 30s
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple targets and pacing options work the same way as in `irtt-cli`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-tui host-a:2112 host-b:2112 --pacing burst
|
|
||||||
```
|
|
||||||
|
|
||||||
Quit with `q` or `Ctrl-C`.
|
|
||||||
|
|
||||||
## Server
|
|
||||||
|
|
||||||
The server applet requires at least one explicit bind address:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server --bind 127.0.0.1:2112
|
|
||||||
```
|
|
||||||
|
|
||||||
The same applet is reachable through the dispatcher:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-rs server --bind 192.0.2.10:2112
|
|
||||||
```
|
|
||||||
|
|
||||||
Repeat `--bind` to serve several addresses from one process, in the order given:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server \
|
|
||||||
--bind 0.0.0.0:2112 \
|
|
||||||
--bind [::]:2112
|
|
||||||
```
|
|
||||||
|
|
||||||
Every invocation runs through the same multi-listener path, so one address is an
|
|
||||||
ordinary set of one rather than a separate mode. The policy options below are
|
|
||||||
applied to every listener, but the listeners are otherwise independent: each has
|
|
||||||
its own sessions and tokens, so a session belongs to the address it was opened
|
|
||||||
on and `--max-sessions` bounds each listener rather than the process. Binding is
|
|
||||||
all or nothing — if any address cannot be bound, none are served — and a port of
|
|
||||||
`0` selects an unused port per listener. If a listener fails while running, the
|
|
||||||
others are shut down with it rather than leaving a service configured for two
|
|
||||||
families answering on one.
|
|
||||||
|
|
||||||
An explicit interface address works on every supported system. A wildcard bind
|
|
||||||
such as `--bind 0.0.0.0:2112` reads each request's local destination from packet
|
|
||||||
metadata and sends that request's reply from the same address, so a client on a
|
|
||||||
multi-homed host is answered from the endpoint it contacted. That is implemented
|
|
||||||
on Linux, macOS and FreeBSD; elsewhere a wildcard bind is refused rather than
|
|
||||||
served from a routing-table source address a client would discard. Wildcard
|
|
||||||
IPv4 and IPv6 listeners may share one port, as above.
|
|
||||||
|
|
||||||
Each bound endpoint is printed on startup once every listener is up, which also
|
|
||||||
resolves a port of `0`. `Ctrl-C` stops the server gracefully.
|
|
||||||
|
|
||||||
Session policy is set with options that map directly onto the server library's
|
|
||||||
configuration; anything left unset keeps the library default:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server \
|
|
||||||
--bind 192.0.2.10:2112 \
|
|
||||||
--max-sessions 512 \
|
|
||||||
--idle-timeout 30s
|
|
||||||
```
|
|
||||||
|
|
||||||
Two optional controls restrict what the server will provide a session, and both
|
|
||||||
are off unless asked for:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server \
|
|
||||||
--bind 192.0.2.10:2112 \
|
|
||||||
--timestamp-allowance single \
|
|
||||||
--no-dscp
|
|
||||||
```
|
|
||||||
|
|
||||||
`--timestamp-allowance` takes `dual` (the default, honoring every requested
|
|
||||||
placement), `single`, which provides at most one timestamp instant and answers a
|
|
||||||
request for both receive and send timestamps with the midpoint, or `none`, which
|
|
||||||
provides no timestamps. The requested clock source is never changed, so a single
|
|
||||||
instant is still reported once per requested clock. `--no-dscp` negotiates any
|
|
||||||
requested DSCP to zero, so clients are told their echo replies will be unmarked,
|
|
||||||
and they are sent unmarked.
|
|
||||||
|
|
||||||
To require authentication, pass the shared key both sides use:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server --bind 192.0.2.10:2112 --hmac secret
|
|
||||||
```
|
|
||||||
|
|
||||||
For the full option list:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-server --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Library usage
|
|
||||||
|
|
||||||
`irtt-client` exposes the client session and event layer independently of CLI formatting and statistics.
|
|
||||||
|
|
||||||
Add it from a local checkout:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
irtt-client = { path = "crates/irtt-client", features = ["tokio"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Minimal managed-client example for a synchronous frontend:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use irtt_client::managed::{
|
|
||||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy,
|
|
||||||
ManagedTargetConfig, TargetId,
|
|
||||||
};
|
|
||||||
use irtt_client::ClientConfig;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let config = ManagedClientConfig {
|
|
||||||
client: ClientConfig {
|
|
||||||
duration: Some(Duration::from_secs(10)),
|
|
||||||
interval: Duration::from_secs(1),
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
|
||||||
..ManagedClientConfig::default()
|
|
||||||
};
|
|
||||||
let targets = vec![ManagedTargetConfig::new(
|
|
||||||
TargetId::from("edge"),
|
|
||||||
"netperf-eu.bufferbloat.net:2112",
|
|
||||||
)];
|
|
||||||
let owner = BlockingManagedClient::start(config, targets)?;
|
|
||||||
let outcome = owner.join()?;
|
|
||||||
println!("session ended: {:?}", outcome.end_reason);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For presentation events, use `BlockingManagedClient::start_with_subscription`,
|
|
||||||
`ManagedEvent`, and `ManagedEventTryRecvError`, while consulting the durable
|
|
||||||
`handle.status()` and final outcome for authoritative state. Presentation
|
|
||||||
events are bounded and lossy, not reliable history. Dynamic desired-target
|
|
||||||
updates are available through `ManagedClientHandle::update_targets`.
|
|
||||||
|
|
||||||
`BlockingManagedClient` owns a dedicated Tokio runtime and thread for
|
|
||||||
synchronous callers. Callers that already own Tokio can drive
|
|
||||||
`ManagedClientTask` directly.
|
|
||||||
|
|
||||||
## Binaries and features
|
## Binaries and features
|
||||||
|
|
||||||
| Build | Binaries |
|
| Build | Binaries |
|
||||||
| ----------------------------------------- | ------------------------------------------------- |
|
| --- | --- |
|
||||||
| `--no-default-features` | `irtt-rs` |
|
| `--no-default-features` | `irtt-rs` |
|
||||||
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
|
||||||
| Default features | `irtt-rs`, `irtt-cli`, `irtt-server` |
|
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
||||||
| `--features tui` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
|
||||||
| `--all-features` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
|
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
|
||||||
|
|
||||||
`irtt-cli` requires the `client` feature, `irtt-server` the `server` feature,
|
|
||||||
and `irtt-tui` the `tui` feature. The `irtt-rs` dispatcher is always built, and
|
|
||||||
reports which applets the build actually contains.
|
|
||||||
|
|
||||||
For available command-line options:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irtt-cli --help
|
|
||||||
irtt-server --help
|
|
||||||
irtt-tui --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Common verification commands:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo fmt --check
|
cargo fmt --all -- --check
|
||||||
cargo test --workspace
|
cargo test --workspace --locked
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo test --workspace --doc --all-features --locked
|
||||||
cargo build -p irtt-cli --all-features --release
|
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project status
|
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
|
||||||
|
clean-room provenance boundary this project maintains against upstream
|
||||||
The client, event stream, machine-readable output, multi-target execution, local statistics, and optional TUI are implemented.
|
`irtt`.
|
||||||
|
|
||||||
The server library and the server applet are implemented: open negotiation,
|
|
||||||
session state, echo processing, per-session rate limiting, idle expiry, the
|
|
||||||
maximum-duration close, HMAC authentication, the negotiated per-session reply
|
|
||||||
traffic class, which is applied on sockets that support it, server fill,
|
|
||||||
wildcard reply-source selection on Linux, macOS and FreeBSD, the optional
|
|
||||||
timestamp and DSCP restriction controls, and serving several listeners from one
|
|
||||||
process with independent sessions per listener.
|
|
||||||
|
|
||||||
Server replies fill their payload with the negotiated ServerFill mode: `none`,
|
|
||||||
which is zero-filled, `rand`, or `pattern:` with a repeating hexadecimal
|
|
||||||
pattern. Every valid descriptor is honored; a descriptor the server cannot
|
|
||||||
parse, and a client that expresses no fill preference at all, get the default
|
|
||||||
`pattern:69727474`.
|
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-cli"
|
name = "irtt-rs"
|
||||||
version = "0.5.0"
|
version = "0.7.1"
|
||||||
description = "Command-line IRTT-compatible client and server"
|
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
|
homepage.workspace = true
|
||||||
readme.workspace = true
|
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"]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
name = "irtt_app"
|
||||||
doc = false
|
doc = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -19,9 +20,12 @@ name = "irtt-rs"
|
|||||||
path = "src/bin/irtt-rs.rs"
|
path = "src/bin/irtt-rs.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "irtt-cli"
|
name = "irtt-client"
|
||||||
path = "src/bin/irtt-cli.rs"
|
path = "src/bin/irtt-client.rs"
|
||||||
required-features = ["client"]
|
required-features = ["client"]
|
||||||
|
# This applet shares its name with the irtt-client library crate, whose rustdoc
|
||||||
|
# output it would otherwise collide with.
|
||||||
|
doc = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "irtt-tui"
|
name = "irtt-tui"
|
||||||
@@ -43,16 +47,22 @@ server = ["dep:irtt-server", "dep:tokio"]
|
|||||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||||
crossterm = { version = "0.29.0", optional = true }
|
crossterm = { version = "0.29.0", optional = true }
|
||||||
ctrlc = "3.5.2"
|
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||||
irtt-client = { workspace = true, optional = true }
|
irtt-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.53.2", default-features = false, features = ["rt", "time"], 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.53.2", default-features = false, features = ["rt", "sync"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# irtt-rs
|
||||||
|
|
||||||
|
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
|
||||||
|
package `irtt-rs`.
|
||||||
|
|
||||||
|
Installing this crate builds:
|
||||||
|
|
||||||
|
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
|
||||||
|
server applet by subcommand.
|
||||||
|
- `irtt-client` — the dedicated client binary (feature `client`).
|
||||||
|
- `irtt-tui` — the interactive terminal UI (feature `tui`).
|
||||||
|
- `irtt-server` — the dedicated server binary (feature `server`).
|
||||||
|
|
||||||
|
All three features are enabled by default, so a plain `cargo install irtt-rs`
|
||||||
|
installs everything.
|
||||||
|
|
||||||
|
## This is an application, not a library
|
||||||
|
|
||||||
|
This package's library target is undocumented (`doc = false`) and exists only
|
||||||
|
to share code between its binaries. If you're looking for the client/session
|
||||||
|
or server API to embed in your own Rust program, use
|
||||||
|
[`irtt-client`](https://crates.io/crates/irtt-client) or
|
||||||
|
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
See the [project repository](https://github.com/Lochnair/irtt-rs) for
|
||||||
|
installation instructions, command-line usage, and configuration.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||||
|
implementation of an IRTT-compatible protocol stack.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
use std::{ffi::OsString, path::Path};
|
||||||
|
|
||||||
|
use clap::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RequestedApplet {
|
||||||
|
Client,
|
||||||
|
Tui,
|
||||||
|
Server,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AppletDispatch {
|
||||||
|
Run {
|
||||||
|
applet: RequestedApplet,
|
||||||
|
argv: Vec<OsString>,
|
||||||
|
},
|
||||||
|
Help(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
||||||
|
match applet_basename(argv0) {
|
||||||
|
"irtt-client" => Some(RequestedApplet::Client),
|
||||||
|
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||||
|
"irtt-server" => Some(RequestedApplet::Server),
|
||||||
|
"irtt-rs" => None,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
|
||||||
|
let argv0 = argv
|
||||||
|
.first()
|
||||||
|
.and_then(|arg| arg.to_str())
|
||||||
|
.unwrap_or("irtt-rs");
|
||||||
|
|
||||||
|
if let Some(applet) = detect_applet_from_argv0(argv0) {
|
||||||
|
return Ok(AppletDispatch::Run { applet, argv });
|
||||||
|
}
|
||||||
|
|
||||||
|
let basename = applet_basename(argv0);
|
||||||
|
if basename.starts_with("irtt-") && basename != "irtt-rs" {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown applet name '{basename}'. Known applet names: {}",
|
||||||
|
known_applet_binary_names()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
|
||||||
|
return Err(format!(
|
||||||
|
"choose an applet: {}\n\n{}",
|
||||||
|
applet_command_names(),
|
||||||
|
dispatcher_help()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||||
|
"client" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Client,
|
||||||
|
argv: applet_argv("irtt-client", &argv[2..]),
|
||||||
|
}),
|
||||||
|
"tui" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Tui,
|
||||||
|
argv: applet_argv("irtt-tui", &argv[2..]),
|
||||||
|
}),
|
||||||
|
"server" => Ok(AppletDispatch::Run {
|
||||||
|
applet: RequestedApplet::Server,
|
||||||
|
argv: applet_argv("irtt-server", &argv[2..]),
|
||||||
|
}),
|
||||||
|
_ => Err(format!(
|
||||||
|
"unknown applet '{command}'. Choose one of: {}\n\n{}",
|
||||||
|
applet_command_names(),
|
||||||
|
dispatcher_help()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatcher_help() -> String {
|
||||||
|
let mut command = Command::new("irtt-rs")
|
||||||
|
.about("IRTT-compatible multi-applet dispatcher")
|
||||||
|
.subcommand(Command::new("client").about(client_applet_about()))
|
||||||
|
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||||
|
.subcommand(Command::new("server").about(server_applet_about()))
|
||||||
|
.after_help(dispatcher_after_help());
|
||||||
|
command.render_help().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_applet_about() -> &'static str {
|
||||||
|
if cfg!(feature = "client") {
|
||||||
|
"Run the stream client applet"
|
||||||
|
} else {
|
||||||
|
"Stream client applet is not available in this build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_applet_about() -> &'static str {
|
||||||
|
if cfg!(feature = "tui") {
|
||||||
|
"Run the terminal UI applet"
|
||||||
|
} else {
|
||||||
|
"Terminal UI applet is not available in this build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_applet_about() -> &'static str {
|
||||||
|
if cfg!(feature = "server") {
|
||||||
|
"Run the UDP server applet"
|
||||||
|
} else {
|
||||||
|
"UDP server applet is not available in this build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_applet_binary_names() -> &'static str {
|
||||||
|
"irtt-rs, irtt-client, irtt-tui, irtt-server"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_command_names() -> &'static str {
|
||||||
|
"client, tui, or server"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher_after_help() -> String {
|
||||||
|
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
|
||||||
|
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
if !cfg!(feature = "client") {
|
||||||
|
missing.push("irtt-client requires the client feature");
|
||||||
|
}
|
||||||
|
if !cfg!(feature = "server") {
|
||||||
|
missing.push("irtt-server requires the server feature");
|
||||||
|
}
|
||||||
|
if !cfg!(feature = "tui") {
|
||||||
|
missing.push("irtt-tui requires the tui feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
match missing.len() {
|
||||||
|
0 => {}
|
||||||
|
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
|
||||||
|
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
|
||||||
|
}
|
||||||
|
help
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_basename(argv0: &str) -> &str {
|
||||||
|
let name = Path::new(argv0)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(argv0);
|
||||||
|
strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the platform executable suffix from a file name.
|
||||||
|
///
|
||||||
|
/// The suffix is part of the file name on Windows and empty everywhere else, so
|
||||||
|
/// this is a no-op on Unix. Without it, every applet name — including the
|
||||||
|
/// `irtt-rs` dispatcher itself — reaches argv0 detection as `<name>.exe` and is
|
||||||
|
/// rejected as an unknown applet. The applet names themselves are still matched
|
||||||
|
/// exactly; only this platform artifact is case-insensitive, because it is not
|
||||||
|
/// a name the user chose.
|
||||||
|
///
|
||||||
|
/// The suffix is a parameter so that Windows behavior is assertable from any
|
||||||
|
/// host, not only where `EXE_SUFFIX` happens to be non-empty.
|
||||||
|
fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str {
|
||||||
|
if suffix.is_empty() || name.len() <= suffix.len() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
let split = name.len() - suffix.len();
|
||||||
|
let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else {
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
if tail.eq_ignore_ascii_case(suffix) {
|
||||||
|
stem
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||||
|
let mut argv = Vec::with_capacity(args.len() + 1);
|
||||||
|
argv.push(OsString::from(applet_name));
|
||||||
|
argv.extend(args.iter().cloned());
|
||||||
|
argv
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
irtt_cli::entry::main()
|
irtt_app::entry::client_main()
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
irtt_cli::entry::main()
|
irtt_app::entry::dispatcher_main()
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
irtt_cli::entry::main()
|
irtt_app::entry::server_main()
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
irtt_cli::entry::main()
|
irtt_app::entry::tui_main()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::shared::client::{
|
||||||
|
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||||
|
ManagedRunSetup, TargetArg, TargetSelection, TimestampArg,
|
||||||
|
};
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
|
||||||
|
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||||
|
pub struct ClientArgs {
|
||||||
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
value_name = "TARGET",
|
||||||
|
num_args = 0..,
|
||||||
|
value_parser = parse_target,
|
||||||
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||||
|
)]
|
||||||
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
|
/// Read complete desired target sets from standard input in continuous mode.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
|
||||||
|
)]
|
||||||
|
pub targets_stdin: bool,
|
||||||
|
|
||||||
|
/// Managed group pacing for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = GroupPacingArg::Staggered,
|
||||||
|
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||||
|
)]
|
||||||
|
pub pacing: GroupPacingArg,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "10s",
|
||||||
|
value_parser = parse_test_duration,
|
||||||
|
help = "Test duration; use 0 for continuous mode",
|
||||||
|
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||||
|
)]
|
||||||
|
pub duration: Duration,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub common: CommonClientArgs,
|
||||||
|
|
||||||
|
/// Event row output format.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = OutputFormat::Table,
|
||||||
|
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
||||||
|
)]
|
||||||
|
pub format: OutputFormat,
|
||||||
|
|
||||||
|
/// Comma-separated event row columns, or default/all.
|
||||||
|
#[arg(
|
||||||
|
short = 'c',
|
||||||
|
long,
|
||||||
|
value_name = "COLUMNS",
|
||||||
|
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
|
||||||
|
)]
|
||||||
|
pub columns: Option<String>,
|
||||||
|
|
||||||
|
/// List available event row columns and aliases, then exit.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
|
||||||
|
)]
|
||||||
|
pub list_columns: bool,
|
||||||
|
|
||||||
|
/// Header policy for table, CSV, and TSV output.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = HeaderMode::Auto,
|
||||||
|
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
|
||||||
|
)]
|
||||||
|
pub header: HeaderMode,
|
||||||
|
|
||||||
|
/// Include extra fields in table output and final summaries.
|
||||||
|
#[arg(long)]
|
||||||
|
pub verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientArgs {
|
||||||
|
/// Validate the selected targets and prepare the managed run.
|
||||||
|
///
|
||||||
|
/// Targets are only required here. `--list-columns` returns before this is
|
||||||
|
/// called, so listing columns still needs no target.
|
||||||
|
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
|
if self.targets_stdin && !self.is_continuous() {
|
||||||
|
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||||
|
}
|
||||||
|
prepare_managed_run(
|
||||||
|
&self.common,
|
||||||
|
self.duration,
|
||||||
|
TargetSelection {
|
||||||
|
targets: &self.targets,
|
||||||
|
pacing: self.pacing,
|
||||||
|
stdin_controlled: self.targets_stdin,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
self.duration == Duration::ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||||
|
self.common.tstamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ClientArgs {
|
||||||
|
type Target = CommonClientArgs;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
/// Readable terminal table output.
|
||||||
|
Table,
|
||||||
|
/// Comma-separated event rows.
|
||||||
|
Csv,
|
||||||
|
/// Tab-separated event rows.
|
||||||
|
Tsv,
|
||||||
|
/// One JSON object per event row.
|
||||||
|
Jsonl,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
pub fn prints_summary(self) -> bool {
|
||||||
|
matches!(self, Self::Table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum HeaderMode {
|
||||||
|
/// Print headers for table, CSV, and TSV output.
|
||||||
|
Auto,
|
||||||
|
/// Always print headers where the format supports them.
|
||||||
|
Always,
|
||||||
|
/// Never print headers.
|
||||||
|
Never,
|
||||||
|
}
|
||||||
File renamed without changes.
+40
-137
@@ -5,7 +5,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
ClientEvent, NegotiationResult, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
||||||
ServerTiming, SignedDuration, WarningKind,
|
ServerTiming, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ enum OutputRow {
|
|||||||
client_send_wall: SystemTime,
|
client_send_wall: SystemTime,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
send_call: Duration,
|
send_call: Duration,
|
||||||
timer_error: Duration,
|
timer_error: Option<Duration>,
|
||||||
},
|
},
|
||||||
EchoReply(ReplyRow),
|
EchoReply(ReplyRow),
|
||||||
Loss {
|
Loss {
|
||||||
@@ -234,22 +234,22 @@ enum OutputRow {
|
|||||||
impl OutputRow {
|
impl OutputRow {
|
||||||
fn from_event(event: &ClientEvent) -> Self {
|
fn from_event(event: &ClientEvent) -> Self {
|
||||||
match event {
|
match event {
|
||||||
ClientEvent::SessionStarted {
|
ClientEvent::SessionStarted(irtt_client::SessionStarted {
|
||||||
remote,
|
remote,
|
||||||
token,
|
token,
|
||||||
negotiated,
|
negotiation: negotiated,
|
||||||
at,
|
at,
|
||||||
} => Self::SessionStarted(LifecycleRow::new(
|
}) => Self::SessionStarted(LifecycleRow::new(
|
||||||
*remote,
|
*remote,
|
||||||
Some(*token),
|
Some(*token),
|
||||||
negotiated,
|
negotiated,
|
||||||
at.wall,
|
at.wall,
|
||||||
)),
|
)),
|
||||||
ClientEvent::NoTestCompleted {
|
ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted {
|
||||||
remote,
|
remote,
|
||||||
negotiated,
|
negotiation: negotiated,
|
||||||
at,
|
at,
|
||||||
} => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
|
}) => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
|
||||||
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
|
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
|
||||||
remote: *remote,
|
remote: *remote,
|
||||||
token: *token,
|
token: *token,
|
||||||
@@ -384,16 +384,19 @@ impl LifecycleRow {
|
|||||||
fn new(
|
fn new(
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
token: Option<u64>,
|
token: Option<u64>,
|
||||||
negotiated: &NegotiatedParams,
|
negotiated: &NegotiationResult,
|
||||||
event_wall: SystemTime,
|
event_wall: SystemTime,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
remote,
|
remote,
|
||||||
token,
|
token,
|
||||||
event_wall,
|
event_wall,
|
||||||
duration_ns: i128::from(negotiated.params.duration_ns),
|
duration_ns: negotiated
|
||||||
interval_ns: i128::from(negotiated.params.interval_ns),
|
.accepted
|
||||||
payload_length: i128::from(negotiated.params.length),
|
.duration
|
||||||
|
.map_or(0, |duration| duration.as_nanos() as i128),
|
||||||
|
interval_ns: negotiated.accepted.interval.as_nanos() as i128,
|
||||||
|
payload_length: i128::from(negotiated.accepted.length),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,6 +805,11 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<
|
|||||||
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
||||||
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
||||||
let width = column.table_width();
|
let width = column.table_width();
|
||||||
|
let value = if matches!(column, Column::Target) {
|
||||||
|
truncate_for_table(&value, width)
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
};
|
||||||
if column.align_right() {
|
if column.align_right() {
|
||||||
format!("{value:>width$}")
|
format!("{value:>width$}")
|
||||||
} else {
|
} else {
|
||||||
@@ -809,6 +817,25 @@ fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shortens `value` to fit within `max_width` display characters for TABLE
|
||||||
|
/// presentation only. Values within the limit are returned unchanged. Longer
|
||||||
|
/// values keep a prefix and end with `"..."` so the result is exactly
|
||||||
|
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
|
||||||
|
/// multibyte UTF-8 value cannot be split mid-code-point.
|
||||||
|
///
|
||||||
|
/// This is a presentation-only concern: callers must keep the untruncated
|
||||||
|
/// value for CSV/TSV/JSONL and any non-display use.
|
||||||
|
fn truncate_for_table(value: &str, max_width: usize) -> String {
|
||||||
|
if value.chars().count() <= max_width {
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
let suffix = "...";
|
||||||
|
let prefix_len = max_width.saturating_sub(suffix.len());
|
||||||
|
let mut truncated: String = value.chars().take(prefix_len).collect();
|
||||||
|
truncated.push_str(suffix);
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum DelimitedFormat {
|
enum DelimitedFormat {
|
||||||
Csv,
|
Csv,
|
||||||
@@ -926,7 +953,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
},
|
},
|
||||||
Column::TimerErrorUs => match row {
|
Column::TimerErrorUs => match row {
|
||||||
OutputRow::EchoSent { timer_error, .. } => {
|
OutputRow::EchoSent { timer_error, .. } => {
|
||||||
Some(CellValue::Unsigned(duration_us(*timer_error)))
|
timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
@@ -1181,127 +1208,3 @@ fn write_json_string_content(out: &mut String, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use irtt_client::ClientTimestamp;
|
|
||||||
use std::{
|
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn output_config(format: OutputFormat, columns: Option<&str>) -> OutputConfig {
|
|
||||||
OutputConfig::new(format, columns, HeaderMode::Auto, false).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote() -> SocketAddr {
|
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reply() -> ClientEvent {
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
wall: UNIX_EPOCH + Duration::from_secs(1),
|
|
||||||
mono: Instant::now(),
|
|
||||||
};
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
wall: sent_at.wall + Duration::from_micros(1200),
|
|
||||||
mono: sent_at.mono + Duration::from_micros(1200),
|
|
||||||
};
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq: 7,
|
|
||||||
remote: remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1200),
|
|
||||||
adjusted: None,
|
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
|
||||||
},
|
|
||||||
server_timing: None,
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_columns_includes_target() {
|
|
||||||
assert!(OutputConfig::list_columns().contains("target"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_table_columns_always_include_target_first() {
|
|
||||||
let header = output_config(OutputFormat::Table, None)
|
|
||||||
.render_header()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(header.split_whitespace().next(), Some("target"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_structured_columns_always_include_target_first() {
|
|
||||||
for format in [OutputFormat::Csv, OutputFormat::Tsv, OutputFormat::Jsonl] {
|
|
||||||
let columns = default_columns(format, false);
|
|
||||||
assert_eq!(columns.first(), Some(&Column::Target));
|
|
||||||
assert_eq!(columns, ALL_COLUMNS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn columns_default_keyword_matches_omitted_columns() {
|
|
||||||
for format in [
|
|
||||||
OutputFormat::Table,
|
|
||||||
OutputFormat::Csv,
|
|
||||||
OutputFormat::Tsv,
|
|
||||||
OutputFormat::Jsonl,
|
|
||||||
] {
|
|
||||||
let omitted = output_config(format, None).render_header();
|
|
||||||
let explicit = output_config(format, Some("default")).render_header();
|
|
||||||
assert_eq!(omitted, explicit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_columns_do_not_insert_target() {
|
|
||||||
let config = output_config(OutputFormat::Csv, Some("seq,effective_rtt_us"));
|
|
||||||
let header = config.render_header().unwrap();
|
|
||||||
|
|
||||||
assert!(!header.split(',').any(|column| column == "target"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_target_column_renders_label() {
|
|
||||||
let config = output_config(OutputFormat::Csv, Some("target,seq,effective_rtt_us"));
|
|
||||||
let line = config
|
|
||||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.render_header().as_deref(),
|
|
||||||
Some("target,seq,effective_rtt_us")
|
|
||||||
);
|
|
||||||
assert_eq!(line, "ams,7,1200");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_csv_renders_target_label_in_row() {
|
|
||||||
let config = output_config(OutputFormat::Csv, None);
|
|
||||||
let line = config
|
|
||||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(line.split(',').next(), Some("ams"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_jsonl_renders_target_key_first() {
|
|
||||||
let config = output_config(OutputFormat::Jsonl, None);
|
|
||||||
let line = config
|
|
||||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(line.starts_with("{\"target\":\"ams\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,940 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
||||||
|
io::{self, BufRead, Write},
|
||||||
|
sync::{atomic::AtomicBool, Arc, Mutex},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_client::{
|
||||||
|
managed::{
|
||||||
|
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
|
||||||
|
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
|
||||||
|
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||||
|
},
|
||||||
|
ClientEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
args::ClientArgs,
|
||||||
|
output::{EventRenderStats, OutputConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::shared::client::{
|
||||||
|
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
|
||||||
|
session::{
|
||||||
|
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||||
|
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
||||||
|
},
|
||||||
|
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_stats::{StatsCollector, StatsConfig};
|
||||||
|
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
const GIB: u64 = 1024 * MIB;
|
||||||
|
|
||||||
|
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
||||||
|
|
||||||
|
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||||
|
|
||||||
|
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||||
|
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||||
|
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
pub fn run_stream(
|
||||||
|
args: ClientArgs,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if args.list_columns {
|
||||||
|
print!("{}", OutputConfig::list_columns());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let setup = args
|
||||||
|
.prepare()
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
let multi_target = setup.is_multi_target();
|
||||||
|
let output_config = OutputConfig::new(
|
||||||
|
args.format,
|
||||||
|
args.columns.as_deref(),
|
||||||
|
args.header,
|
||||||
|
args.verbose,
|
||||||
|
)
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
if setup.stdin_controlled {
|
||||||
|
return run_stdin_stream(setup, output_config, shutdown_requested);
|
||||||
|
}
|
||||||
|
let continuous = args.is_continuous();
|
||||||
|
let target_count = setup.target_count();
|
||||||
|
|
||||||
|
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
||||||
|
setup.managed_config(),
|
||||||
|
setup.managed_targets(),
|
||||||
|
)?;
|
||||||
|
let handle = owner.handle();
|
||||||
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config,
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut stdout,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stats = setup
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| {
|
||||||
|
(
|
||||||
|
target.label.clone(),
|
||||||
|
StatsCollector::new(stats_config(continuous)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut terminal_targets = HashSet::new();
|
||||||
|
let mut dropped_events = 0_u64;
|
||||||
|
let mut interrupted = false;
|
||||||
|
let mut stop_requested = false;
|
||||||
|
let mut subscription_closed = false;
|
||||||
|
loop {
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
interrupted = true;
|
||||||
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
|
drop(handle.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request_managed_stop_for_peer_close(
|
||||||
|
continuous,
|
||||||
|
interrupted,
|
||||||
|
handle.status().peer_closed_target_outcomes,
|
||||||
|
&mut stop_requested,
|
||||||
|
) {
|
||||||
|
drop(handle.stop());
|
||||||
|
}
|
||||||
|
let drain_state = drain_events(
|
||||||
|
&mut events,
|
||||||
|
&mut stream_output,
|
||||||
|
&mut stats,
|
||||||
|
&mut terminal_targets,
|
||||||
|
&mut dropped_events,
|
||||||
|
)?;
|
||||||
|
match drain_state {
|
||||||
|
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
||||||
|
ManagedDrainState::Closed => subscription_closed = true,
|
||||||
|
ManagedDrainState::BudgetExhausted => {}
|
||||||
|
}
|
||||||
|
if handle.status().final_outcome.is_some() || subscription_closed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interrupted {
|
||||||
|
eprintln!("interrupted, closing managed run...");
|
||||||
|
}
|
||||||
|
let outcome = owner.join()?;
|
||||||
|
drain_final_events(
|
||||||
|
&mut events,
|
||||||
|
&mut stream_output,
|
||||||
|
&mut stats,
|
||||||
|
&mut terminal_targets,
|
||||||
|
&mut dropped_events,
|
||||||
|
)?;
|
||||||
|
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
if outcome.discarded_target_outcomes != 0 {
|
||||||
|
eprintln!(
|
||||||
|
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||||
|
outcome.discarded_target_outcomes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for target in outcome.recent_target_outcomes.iter() {
|
||||||
|
if terminal_targets.insert(target.target.clone()) {
|
||||||
|
report_target_failure(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||||
|
let terminal_error = match &outcome.end_reason {
|
||||||
|
ManagedEndReason::DriverFailed(failure) => {
|
||||||
|
Some(format!("managed driver failed: {failure}"))
|
||||||
|
}
|
||||||
|
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
||||||
|
.or_else(|| {
|
||||||
|
(!interrupted
|
||||||
|
&& outcome.successful_target_outcomes == 0
|
||||||
|
&& outcome.failed_target_outcomes > 0)
|
||||||
|
.then(|| {
|
||||||
|
format!(
|
||||||
|
"no managed target completed successfully ({} failed)",
|
||||||
|
outcome.failed_target_outcomes
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||||
|
stream_output.show_running_only_summary_note =
|
||||||
|
continuous && interrupted && stream_output.print_final_summary;
|
||||||
|
for target in &setup.targets {
|
||||||
|
let target_stats = stats.get(&target.label).expect(
|
||||||
|
"every prepared target must have a stats collector inserted before the run begins",
|
||||||
|
);
|
||||||
|
if multi_target
|
||||||
|
&& stream_output.print_final_summary
|
||||||
|
&& stream_output.config.prints_summary()
|
||||||
|
{
|
||||||
|
writeln!(stream_output.out)?;
|
||||||
|
writeln!(stream_output.out, "target: {}", target.label)?;
|
||||||
|
}
|
||||||
|
stream_output.print_summary(target_stats)?;
|
||||||
|
}
|
||||||
|
stream_output.out.flush()?;
|
||||||
|
if let Some(error) = terminal_error {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct StdinTargetSet {
|
||||||
|
revision: u64,
|
||||||
|
targets: Vec<ManagedTargetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StdinMailboxState {
|
||||||
|
next_revision: u64,
|
||||||
|
latest: Option<StdinTargetSet>,
|
||||||
|
eof: bool,
|
||||||
|
fatal: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StdinMailbox {
|
||||||
|
state: Mutex<StdinMailboxState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdinMailbox {
|
||||||
|
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
state.next_revision = state.next_revision.saturating_add(1);
|
||||||
|
state.latest = Some(StdinTargetSet {
|
||||||
|
revision: state.next_revision,
|
||||||
|
targets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self) {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.eof = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(&self, error: String) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
state.fatal = Some(error);
|
||||||
|
state.latest = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest(&self) -> Option<StdinTargetSet> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.latest
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_if_current(&self, revision: u64) {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if state
|
||||||
|
.latest
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|set| set.revision == revision)
|
||||||
|
{
|
||||||
|
state.latest = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_current(&self, revision: u64) -> bool {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.latest
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|set| set.revision == revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fatal(&self) -> Option<String> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.fatal
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_finished(&self) -> bool {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.eof
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_stdin_record<R: BufRead>(
|
||||||
|
reader: &mut R,
|
||||||
|
buffer: &mut Vec<u8>,
|
||||||
|
) -> io::Result<Option<String>> {
|
||||||
|
buffer.clear();
|
||||||
|
loop {
|
||||||
|
let available = reader.fill_buf()?;
|
||||||
|
if available.is_empty() {
|
||||||
|
if buffer.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"stdin target record exceeds the maximum size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::str::from_utf8(buffer)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
|
||||||
|
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
|
||||||
|
reader.consume(1);
|
||||||
|
buffer.pop();
|
||||||
|
return std::str::from_utf8(buffer)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||||
|
}
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"stdin target record exceeds the maximum size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
|
||||||
|
let before_newline = &available[..newline];
|
||||||
|
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"stdin target record exceeds the maximum size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(before_newline);
|
||||||
|
reader.consume(newline + 1);
|
||||||
|
if buffer.last() == Some(&b'\r') {
|
||||||
|
buffer.pop();
|
||||||
|
}
|
||||||
|
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"stdin target record exceeds the maximum size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::str::from_utf8(buffer)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
|
||||||
|
let take = available.len().min(remaining);
|
||||||
|
buffer.extend_from_slice(&available[..take]);
|
||||||
|
reader.consume(take);
|
||||||
|
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"stdin target record exceeds the maximum size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
|
||||||
|
let mut line = 0_u64;
|
||||||
|
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||||
|
loop {
|
||||||
|
match read_stdin_record(reader, &mut record) {
|
||||||
|
Ok(Some(record)) => {
|
||||||
|
line = line.saturating_add(1);
|
||||||
|
if record.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||||
|
Ok(targets) => {
|
||||||
|
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
mailbox.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("irtt-targets-stdin".to_owned())
|
||||||
|
.spawn(move || {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
read_stdin_target_sets(&mut stdin.lock(), &mailbox);
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RetryAfterStatus {
|
||||||
|
revision: u64,
|
||||||
|
status: Arc<ManagedStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_waits_for_status_change(
|
||||||
|
retry_after: &mut Option<RetryAfterStatus>,
|
||||||
|
target_set: &StdinTargetSet,
|
||||||
|
status: &Arc<ManagedStatus>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(retry) = retry_after else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if retry.revision != target_set.revision {
|
||||||
|
*retry_after = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Arc::ptr_eq(&retry.status, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_after_capacity_rejection(
|
||||||
|
mailbox: &StdinMailbox,
|
||||||
|
target_set: &StdinTargetSet,
|
||||||
|
status: Arc<ManagedStatus>,
|
||||||
|
) -> Option<RetryAfterStatus> {
|
||||||
|
mailbox
|
||||||
|
.is_current(target_set.revision)
|
||||||
|
.then_some(RetryAfterStatus {
|
||||||
|
revision: target_set.revision,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_latest_stdin_target_set(
|
||||||
|
handle: &ManagedClientHandle,
|
||||||
|
mailbox: &StdinMailbox,
|
||||||
|
retry_after: &mut Option<RetryAfterStatus>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(target_set) = mailbox.latest() else {
|
||||||
|
*retry_after = None;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let status = handle.status();
|
||||||
|
if retry_waits_for_status_change(retry_after, &target_set, &status) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let receipt = handle
|
||||||
|
.update_targets(target_set.targets.clone())
|
||||||
|
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
|
||||||
|
match receipt.blocking_wait() {
|
||||||
|
Ok(_) => {
|
||||||
|
mailbox.clear_if_current(target_set.revision);
|
||||||
|
*retry_after = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||||
|
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BoundedTargetSet {
|
||||||
|
limit: usize,
|
||||||
|
order: VecDeque<TargetInstance>,
|
||||||
|
members: HashSet<TargetInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundedTargetSet {
|
||||||
|
fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
limit,
|
||||||
|
order: VecDeque::with_capacity(limit),
|
||||||
|
members: HashSet::with_capacity(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, target: TargetInstance) -> bool {
|
||||||
|
if !self.members.insert(target.clone()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.order.len() == self.limit {
|
||||||
|
let evicted = self
|
||||||
|
.order
|
||||||
|
.pop_front()
|
||||||
|
.expect("bounded target order is nonempty");
|
||||||
|
self.members.remove(&evicted);
|
||||||
|
}
|
||||||
|
self.order.push_back(target);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_stdin_event<W: Write>(
|
||||||
|
event: ManagedEvent,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
|
terminal_targets: &mut BoundedTargetSet,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
match event {
|
||||||
|
ManagedEvent::TargetStateChanged { target, .. } => {
|
||||||
|
stats
|
||||||
|
.entry(target)
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||||
|
}
|
||||||
|
ManagedEvent::Client { target, event } => {
|
||||||
|
let collector = stats
|
||||||
|
.entry(target.clone())
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target.id.as_str()),
|
||||||
|
collector,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
ManagedEvent::TargetFinished { outcome }
|
||||||
|
if terminal_targets.insert(outcome.target.clone()) =>
|
||||||
|
{
|
||||||
|
report_target_failure(&outcome);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_stdin_events<W: Write>(
|
||||||
|
events: &mut ManagedEventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
|
terminal_targets: &mut BoundedTargetSet,
|
||||||
|
dropped_events: &mut u64,
|
||||||
|
) -> io::Result<ManagedDrainState> {
|
||||||
|
drain_managed_events(events, dropped_events, |event| {
|
||||||
|
process_stdin_event(event, stream_output, stats, terminal_targets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_final_stdin_events<W: Write>(
|
||||||
|
events: &mut ManagedEventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
|
terminal_targets: &mut BoundedTargetSet,
|
||||||
|
dropped_events: &mut u64,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
|
||||||
|
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||||
|
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||||
|
*dropped_events = dropped_events.saturating_add(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconcile_stdin_stats(
|
||||||
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
|
status: &ManagedStatus,
|
||||||
|
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
|
||||||
|
) {
|
||||||
|
let live = status
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.target.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
stats.retain(|target, _| {
|
||||||
|
live.contains(target)
|
||||||
|
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_stdin_summary_targets(
|
||||||
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
|
status: &ManagedStatus,
|
||||||
|
) -> BTreeSet<TargetInstance> {
|
||||||
|
let targets = status
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.target.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
for target in &targets {
|
||||||
|
stats
|
||||||
|
.entry(target.clone())
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||||
|
}
|
||||||
|
targets
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum StdinStop {
|
||||||
|
Interrupted,
|
||||||
|
Eof,
|
||||||
|
Fatal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdin_stop_request(
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
mailbox: &StdinMailbox,
|
||||||
|
) -> Option<StdinStop> {
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
Some(StdinStop::Interrupted)
|
||||||
|
} else if let Some(error) = mailbox.fatal() {
|
||||||
|
Some(StdinStop::Fatal(error))
|
||||||
|
} else if mailbox.is_finished() {
|
||||||
|
Some(StdinStop::Eof)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_stdin_stream(
|
||||||
|
setup: ManagedRunSetup,
|
||||||
|
output_config: OutputConfig,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if is_shutdown_requested(shutdown_requested) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
||||||
|
setup.managed_config(),
|
||||||
|
setup.managed_targets(),
|
||||||
|
)?;
|
||||||
|
let handle = owner.handle();
|
||||||
|
let mailbox = Arc::new(StdinMailbox::default());
|
||||||
|
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
|
||||||
|
|
||||||
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
|
let mut stream_output = StreamOutput {
|
||||||
|
config: output_config,
|
||||||
|
header_printed: false,
|
||||||
|
print_final_summary: false,
|
||||||
|
show_running_only_summary_note: false,
|
||||||
|
out: &mut stdout,
|
||||||
|
};
|
||||||
|
let mut stats = BTreeMap::new();
|
||||||
|
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||||
|
let mut retry_after = None;
|
||||||
|
let mut dropped_events = 0_u64;
|
||||||
|
let mut stop_requested = false;
|
||||||
|
let mut stop = None;
|
||||||
|
let mut summary_targets = None;
|
||||||
|
let mut subscription_closed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let status = handle.status();
|
||||||
|
if stop.is_none() {
|
||||||
|
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
||||||
|
stop = Some(requested_stop);
|
||||||
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
|
drop(handle.stop());
|
||||||
|
}
|
||||||
|
} else if let Err(error) =
|
||||||
|
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
|
||||||
|
{
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
||||||
|
stop = Some(StdinStop::Fatal(error));
|
||||||
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
|
drop(handle.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let drain_state = drain_stdin_events(
|
||||||
|
&mut events,
|
||||||
|
&mut stream_output,
|
||||||
|
&mut stats,
|
||||||
|
&mut terminal_targets,
|
||||||
|
&mut dropped_events,
|
||||||
|
)?;
|
||||||
|
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
|
||||||
|
match drain_state {
|
||||||
|
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
||||||
|
ManagedDrainState::Closed => subscription_closed = true,
|
||||||
|
ManagedDrainState::BudgetExhausted => {}
|
||||||
|
}
|
||||||
|
if handle.status().final_outcome.is_some() || subscription_closed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = owner.join()?;
|
||||||
|
drain_final_stdin_events(
|
||||||
|
&mut events,
|
||||||
|
&mut stream_output,
|
||||||
|
&mut stats,
|
||||||
|
&mut terminal_targets,
|
||||||
|
&mut dropped_events,
|
||||||
|
)?;
|
||||||
|
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
for target in outcome.recent_target_outcomes.iter() {
|
||||||
|
if terminal_targets.insert(target.target.clone()) {
|
||||||
|
report_target_failure(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outcome.discarded_target_outcomes != 0 {
|
||||||
|
eprintln!(
|
||||||
|
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||||
|
outcome.discarded_target_outcomes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
|
||||||
|
stream_output.print_final_summary = print_summary;
|
||||||
|
stream_output.show_running_only_summary_note = print_summary;
|
||||||
|
if let Some(summary_targets) = summary_targets {
|
||||||
|
let multi_target = summary_targets.len() > 1;
|
||||||
|
for target in summary_targets {
|
||||||
|
let stats = stats
|
||||||
|
.get(&target)
|
||||||
|
.expect("summary snapshot retains every selected target collector");
|
||||||
|
if multi_target && stream_output.config.prints_summary() {
|
||||||
|
writeln!(stream_output.out)?;
|
||||||
|
writeln!(
|
||||||
|
stream_output.out,
|
||||||
|
"target: {} (generation {})",
|
||||||
|
target.id, target.generation
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
stream_output.print_summary(stats)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream_output.out.flush()?;
|
||||||
|
|
||||||
|
match stop {
|
||||||
|
Some(StdinStop::Fatal(error)) => Err(error.into()),
|
||||||
|
_ => match outcome.end_reason {
|
||||||
|
ManagedEndReason::DriverFailed(failure) => {
|
||||||
|
Err(format!("managed driver failed: {failure}").into())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_event<W: Write>(
|
||||||
|
event: ManagedEvent,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<String, StatsCollector>,
|
||||||
|
terminal_targets: &mut HashSet<TargetInstance>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
match event {
|
||||||
|
ManagedEvent::Client { target, event } => {
|
||||||
|
let collector = stats
|
||||||
|
.entry(target.id.as_str().to_owned())
|
||||||
|
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
||||||
|
print_events_with_stats(
|
||||||
|
stream_output,
|
||||||
|
std::slice::from_ref(&event),
|
||||||
|
Some(target.id.as_str()),
|
||||||
|
collector,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
ManagedEvent::TargetFinished { outcome } => {
|
||||||
|
terminal_targets.insert(outcome.target.clone());
|
||||||
|
report_target_failure(&outcome);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_events<W: Write>(
|
||||||
|
events: &mut ManagedEventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<String, StatsCollector>,
|
||||||
|
terminal_targets: &mut HashSet<TargetInstance>,
|
||||||
|
dropped_events: &mut u64,
|
||||||
|
) -> io::Result<ManagedDrainState> {
|
||||||
|
drain_managed_events(events, dropped_events, |event| {
|
||||||
|
process_event(event, stream_output, stats, terminal_targets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_final_events<W: Write>(
|
||||||
|
events: &mut ManagedEventSubscription,
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
stats: &mut BTreeMap<String, StatsCollector>,
|
||||||
|
terminal_targets: &mut HashSet<TargetInstance>,
|
||||||
|
dropped_events: &mut u64,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
|
||||||
|
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||||
|
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||||
|
*dropped_events = dropped_events.saturating_add(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_target_failure(target: &ManagedTargetOutcome) {
|
||||||
|
for message in target_failure_messages(target) {
|
||||||
|
eprintln!("{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
|
||||||
|
let mut messages = Vec::with_capacity(2);
|
||||||
|
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
|
||||||
|
messages.push(format!(
|
||||||
|
"irtt-rs: target {} failed ({} {}): {}",
|
||||||
|
target.target.id, failure.phase, failure.kind, failure.message
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(failure) = &target.cleanup_failure {
|
||||||
|
messages.push(format!(
|
||||||
|
"irtt-rs: target {} cleanup failed ({} {}): {}",
|
||||||
|
target.target.id, failure.phase, failure.kind, failure.message
|
||||||
|
));
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
||||||
|
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamOutput<'a, W: Write> {
|
||||||
|
config: OutputConfig,
|
||||||
|
header_printed: bool,
|
||||||
|
print_final_summary: bool,
|
||||||
|
show_running_only_summary_note: bool,
|
||||||
|
out: &'a mut W,
|
||||||
|
}
|
||||||
|
impl<W: Write> StreamOutput<'_, W> {
|
||||||
|
fn print_events(
|
||||||
|
&mut self,
|
||||||
|
events: &[ClientEvent],
|
||||||
|
target: Option<&str>,
|
||||||
|
stats_updates: &[EventRenderStats],
|
||||||
|
) -> io::Result<()> {
|
||||||
|
self.print_header()?;
|
||||||
|
for (event, stats_update) in events.iter().zip(stats_updates) {
|
||||||
|
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
|
||||||
|
writeln!(self.out, "{line}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn print_header(&mut self) -> io::Result<()> {
|
||||||
|
if self.header_printed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.header_printed = true;
|
||||||
|
if let Some(header) = self.config.render_header() {
|
||||||
|
writeln!(self.out, "{header}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
||||||
|
if self.print_final_summary && self.config.prints_summary() {
|
||||||
|
write!(
|
||||||
|
self.out,
|
||||||
|
"{}",
|
||||||
|
crate::cmd::client::summary::format_summary_with_options(
|
||||||
|
&stats.snapshot(),
|
||||||
|
crate::cmd::client::summary::SummaryFormatOptions {
|
||||||
|
verbose: self.config.summary_verbose(),
|
||||||
|
show_running_only_note: self.show_running_only_summary_note
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_events_with_stats<W: Write>(
|
||||||
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
|
events: &[ClientEvent],
|
||||||
|
target: Option<&str>,
|
||||||
|
stats: &mut StatsCollector,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let updates = events
|
||||||
|
.iter()
|
||||||
|
.map(|event| EventRenderStats::from(stats.process(event)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
stream_output.print_events(events, target, &updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stats_config(continuous: bool) -> StatsConfig {
|
||||||
|
if continuous {
|
||||||
|
StatsConfig::continuous()
|
||||||
|
} else {
|
||||||
|
StatsConfig::finite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
|
||||||
|
if args.is_continuous() || args.duration.is_zero() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
||||||
|
let total_probe_count =
|
||||||
|
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
|
||||||
|
// Ask the stats crate what the configuration this run will actually use
|
||||||
|
// retains; the CLI owns the probe count and the thresholds, not the
|
||||||
|
// retention model.
|
||||||
|
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
|
||||||
|
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let formatted = if estimated_bytes >= GIB {
|
||||||
|
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
|
||||||
|
} else {
|
||||||
|
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
|
||||||
|
};
|
||||||
|
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
|
||||||
|
"this may be unsuitable on memory-constrained systems"
|
||||||
|
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
|
||||||
|
"consider shortening the run, increasing the interval, or using continuous mode"
|
||||||
|
} else {
|
||||||
|
"use continuous mode for bounded-memory long-running tests"
|
||||||
|
};
|
||||||
|
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use irtt_stats::{Snapshot, TimeStats};
|
||||||
|
|
||||||
|
use crate::cmd::format::{
|
||||||
|
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
|
||||||
|
format_optional_ns_i128 as format_ns_i128, format_percent,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn format_summary(summary: &Snapshot) -> String {
|
||||||
|
format_summary_with_options(summary, SummaryFormatOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct SummaryFormatOptions {
|
||||||
|
pub verbose: bool,
|
||||||
|
pub show_running_only_note: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let packets = summary.packets;
|
||||||
|
let loss = summary.loss;
|
||||||
|
|
||||||
|
writeln!(out).unwrap();
|
||||||
|
writeln!(out, "irtt-rs summary").unwrap();
|
||||||
|
if options.show_running_only_note {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
writeln!(out).unwrap();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||||
|
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(out, " {}", "-".repeat(82)).unwrap();
|
||||||
|
|
||||||
|
write_time_row(&mut out, "RTT", &summary.rtt.primary);
|
||||||
|
if options.verbose {
|
||||||
|
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
|
||||||
|
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
|
||||||
|
}
|
||||||
|
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
|
||||||
|
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
|
||||||
|
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
|
||||||
|
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
|
||||||
|
write_time_row(
|
||||||
|
&mut out,
|
||||||
|
"receive delay",
|
||||||
|
&summary.one_way_delay.receive_delay,
|
||||||
|
);
|
||||||
|
write_time_row(
|
||||||
|
&mut out,
|
||||||
|
"server processing",
|
||||||
|
&summary.server_processing.processing,
|
||||||
|
);
|
||||||
|
write_time_row(&mut out, "send call", &summary.send_call);
|
||||||
|
write_time_row(&mut out, "timer error", &summary.timer_error);
|
||||||
|
|
||||||
|
writeln!(out).unwrap();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"packets: sent={} received={} unique={} lost={} loss={}",
|
||||||
|
packets.packets_sent,
|
||||||
|
packets.packets_received,
|
||||||
|
packets.unique_replies,
|
||||||
|
loss.lost_packets,
|
||||||
|
format_percent(loss.packet_loss_percent)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if packets.duplicates != 0 || packets.late_packets != 0 {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"replies: duplicates={} ({}) late={} ({})",
|
||||||
|
packets.duplicates,
|
||||||
|
format_percent(loss.duplicate_percent),
|
||||||
|
packets.late_packets,
|
||||||
|
format_percent(loss.late_packets_percent)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"bytes: sent={} received={}",
|
||||||
|
packets.bytes_sent, packets.bytes_received
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
|
||||||
|
write!(out, "server:").unwrap();
|
||||||
|
if let Some(count) = packets.server_packets_received {
|
||||||
|
write!(out, " received={count}").unwrap();
|
||||||
|
}
|
||||||
|
if let Some(window) = packets.server_received_window {
|
||||||
|
write!(out, " window={window:#x}").unwrap();
|
||||||
|
}
|
||||||
|
writeln!(out).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
||||||
|
if value.count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||||
|
value.count,
|
||||||
|
format_ns_i128(value.min_ns),
|
||||||
|
format_ns_f64(value.mean_ns),
|
||||||
|
format_ns_f64_opt(value.median_ns),
|
||||||
|
format_ns_i128(value.max_ns),
|
||||||
|
format_ns_f64(value.stddev_ns())
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
use irtt_client::SignedDuration;
|
use irtt_client::SignedDuration;
|
||||||
|
|
||||||
/// Rendering used wherever a scalar is unavailable.
|
/// Rendering used wherever a scalar is unavailable.
|
||||||
@@ -61,6 +62,7 @@ pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||||
|
#[cfg(feature = "client")]
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ pub(crate) fn format_duration(value: Duration) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format a signed duration as a nanosecond scalar, preserving its sign.
|
/// Format a signed duration as a nanosecond scalar, preserving its sign.
|
||||||
|
#[cfg(feature = "client")]
|
||||||
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
||||||
format_ns_i128(value.as_nanos())
|
format_ns_i128(value.as_nanos())
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,7 @@ pub(crate) fn format_percent(value: f64) -> String {
|
|||||||
/// Format `value` as a percentage of `total`, capped at 100%.
|
/// Format `value` as a percentage of `total`, capped at 100%.
|
||||||
///
|
///
|
||||||
/// A zero total has no percentage to report and renders as [`ABSENT`].
|
/// A zero total has no percentage to report and renders as [`ABSENT`].
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
ABSENT.to_owned()
|
ABSENT.to_owned()
|
||||||
@@ -97,11 +101,13 @@ pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format a scalar count.
|
/// Format a scalar count.
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
pub(crate) fn format_count(value: u64) -> String {
|
pub(crate) fn format_count(value: u64) -> String {
|
||||||
value.to_string()
|
value.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
|
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
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)
|
||||||
}
|
}
|
||||||
@@ -109,73 +115,3 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
|||||||
fn absent() -> String {
|
fn absent() -> String {
|
||||||
ABSENT.to_owned()
|
ABSENT.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
|
|
||||||
assert_eq!(format_ns_f64(0.0), "0ns");
|
|
||||||
assert_eq!(format_ns_f64(999.0), "999ns");
|
|
||||||
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
|
|
||||||
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
|
|
||||||
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
|
|
||||||
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
|
|
||||||
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
|
|
||||||
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negative_nanosecond_scalars_keep_their_sign() {
|
|
||||||
assert_eq!(format_ns_f64(-500.0), "-500ns");
|
|
||||||
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
|
|
||||||
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
|
|
||||||
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
|
|
||||||
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exact_and_floating_nanosecond_scalars_agree() {
|
|
||||||
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
|
|
||||||
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn microseconds_use_the_micro_sign() {
|
|
||||||
assert!(format_ns_f64(1_500.0).ends_with("µs"));
|
|
||||||
assert!(!format_ns_f64(1_500.0).contains("us"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn durations_render_as_nanosecond_scalars() {
|
|
||||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
|
||||||
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
|
||||||
assert_eq!(
|
|
||||||
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
|
||||||
"-1.5µs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
|
||||||
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
|
||||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
|
||||||
assert_eq!(format_optional_count(None), ABSENT);
|
|
||||||
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
|
||||||
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
|
||||||
assert_eq!(format_percent(f64::NAN), ABSENT);
|
|
||||||
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn percentages_and_counts_have_one_spelling() {
|
|
||||||
assert_eq!(format_percent(0.0), "0.00%");
|
|
||||||
assert_eq!(format_percent(12.345), "12.35%");
|
|
||||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
|
||||||
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
|
||||||
assert_eq!(format_count(42), "42");
|
|
||||||
assert_eq!(format_optional_count(Some(0)), "0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File renamed without changes.
@@ -1,8 +1,14 @@
|
|||||||
use std::{net::SocketAddr, time::Duration};
|
use std::{
|
||||||
|
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
use irtt_server::{ServerConfig, TimestampAllowance};
|
use irtt_server::{ServerConfig, TimestampAllowance};
|
||||||
|
|
||||||
|
/// The ordinary IRTT port, used to build the zero-argument default binds.
|
||||||
|
const DEFAULT_PORT: u16 = 2112;
|
||||||
|
|
||||||
// Every policy option is optional. An omitted option leaves the corresponding
|
// 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.
|
||||||
@@ -10,15 +16,15 @@ use irtt_server::{ServerConfig, TimestampAllowance};
|
|||||||
#[command(
|
#[command(
|
||||||
name = "irtt-server",
|
name = "irtt-server",
|
||||||
about = "Minimal IRTT-compatible UDP server",
|
about = "Minimal IRTT-compatible UDP server",
|
||||||
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
|
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
|
||||||
)]
|
)]
|
||||||
pub struct ServerArgs {
|
pub struct ServerArgs {
|
||||||
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
|
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
required = true,
|
|
||||||
value_name = "ADDR",
|
value_name = "ADDR",
|
||||||
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
|
env = "IRTT_SERVER_BIND",
|
||||||
|
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports.\n\nIRTT_SERVER_BIND sets the same thing for deployments that configure by environment (systemd, Docker, ...), and takes exactly one address, the same as one --bind. Leave it unset on ordinary hosts: the wildcard default above is already what you want there. A --bind on the command line overrides it entirely."
|
||||||
)]
|
)]
|
||||||
pub bind: Vec<SocketAddr>,
|
pub bind: Vec<SocketAddr>,
|
||||||
|
|
||||||
@@ -26,7 +32,9 @@ pub struct ServerArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "KEY",
|
value_name = "KEY",
|
||||||
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
|
value_parser = parse_hmac_key,
|
||||||
|
env = "IRTT_SERVER_HMAC",
|
||||||
|
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments, or in the environment as IRTT_SERVER_HMAC."
|
||||||
)]
|
)]
|
||||||
pub hmac: Option<String>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
@@ -34,6 +42,7 @@ pub struct ServerArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "COUNT",
|
value_name = "COUNT",
|
||||||
|
env = "IRTT_SERVER_MAX_SESSIONS",
|
||||||
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
|
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
|
||||||
)]
|
)]
|
||||||
pub max_sessions: Option<usize>,
|
pub max_sessions: Option<usize>,
|
||||||
@@ -42,16 +51,18 @@ pub struct ServerArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "BYTES",
|
value_name = "BYTES",
|
||||||
|
env = "IRTT_SERVER_MAX_PACKET_LENGTH",
|
||||||
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
|
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
|
||||||
)]
|
)]
|
||||||
pub max_packet_length: Option<usize>,
|
pub max_packet_length: Option<usize>,
|
||||||
|
|
||||||
/// Floor on the negotiated send interval, and the allowance refill cadence.
|
/// Floor on the negotiated send interval.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "DURATION",
|
value_name = "DURATION",
|
||||||
value_parser = parse_duration_allow_zero,
|
value_parser = parse_duration_allow_zero,
|
||||||
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
|
env = "IRTT_SERVER_MIN_INTERVAL",
|
||||||
|
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
|
||||||
)]
|
)]
|
||||||
pub min_interval: Option<Duration>,
|
pub min_interval: Option<Duration>,
|
||||||
|
|
||||||
@@ -59,6 +70,7 @@ pub struct ServerArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "COUNT",
|
value_name = "COUNT",
|
||||||
|
env = "IRTT_SERVER_BURST",
|
||||||
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
|
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
|
||||||
)]
|
)]
|
||||||
pub burst: Option<u32>,
|
pub burst: Option<u32>,
|
||||||
@@ -68,6 +80,7 @@ pub struct ServerArgs {
|
|||||||
long,
|
long,
|
||||||
value_name = "DURATION",
|
value_name = "DURATION",
|
||||||
value_parser = parse_duration_allow_zero,
|
value_parser = parse_duration_allow_zero,
|
||||||
|
env = "IRTT_SERVER_IDLE_TIMEOUT",
|
||||||
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
|
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
|
||||||
)]
|
)]
|
||||||
pub idle_timeout: Option<Duration>,
|
pub idle_timeout: Option<Duration>,
|
||||||
@@ -77,6 +90,7 @@ pub struct ServerArgs {
|
|||||||
long,
|
long,
|
||||||
value_name = "DURATION",
|
value_name = "DURATION",
|
||||||
value_parser = parse_positive_duration,
|
value_parser = parse_positive_duration,
|
||||||
|
env = "IRTT_SERVER_MAX_DURATION",
|
||||||
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
|
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
|
||||||
)]
|
)]
|
||||||
pub max_duration: Option<Duration>,
|
pub max_duration: Option<Duration>,
|
||||||
@@ -86,14 +100,28 @@ pub struct ServerArgs {
|
|||||||
long,
|
long,
|
||||||
value_name = "MODE",
|
value_name = "MODE",
|
||||||
value_enum,
|
value_enum,
|
||||||
|
env = "IRTT_SERVER_TIMESTAMP_ALLOWANCE",
|
||||||
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
|
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
|
||||||
)]
|
)]
|
||||||
pub timestamp_allowance: Option<TimestampAllowanceArg>,
|
pub timestamp_allowance: Option<TimestampAllowanceArg>,
|
||||||
|
|
||||||
/// Refuse to provide requested DSCP marking.
|
/// Refuse to provide requested DSCP marking.
|
||||||
|
//
|
||||||
|
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
|
||||||
|
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
|
||||||
|
// all. A flag's default parser accepts only the literals `true` and
|
||||||
|
// `false`, so the ordinary container/orchestrator spelling
|
||||||
|
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
|
||||||
|
// parsing and the server refused to start. Falsey semantics are clap's own
|
||||||
|
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
|
||||||
|
// `n`, `off` and empty are off, anything else is on, and an absent
|
||||||
|
// variable is off as before. The command-line flag is unaffected: it still
|
||||||
|
// takes no value and still means "on".
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
|
env = "IRTT_SERVER_NO_DSCP",
|
||||||
|
value_parser = clap::builder::FalseyValueParser::new(),
|
||||||
|
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
|
||||||
)]
|
)]
|
||||||
pub no_dscp: bool,
|
pub no_dscp: bool,
|
||||||
}
|
}
|
||||||
@@ -123,6 +151,20 @@ impl From<TimestampAllowanceArg> for TimestampAllowance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ServerArgs {
|
impl ServerArgs {
|
||||||
|
/// The addresses this invocation binds: the requested `--bind` list, or
|
||||||
|
/// the wildcard IRTT port on both address families if none was given.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
|
||||||
|
if self.bind.is_empty() {
|
||||||
|
vec![
|
||||||
|
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||||
|
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
self.bind.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the server configuration this invocation asks for.
|
/// Builds the server configuration this invocation asks for.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn server_config(&self) -> ServerConfig {
|
pub fn server_config(&self) -> ServerConfig {
|
||||||
@@ -160,6 +202,15 @@ impl ServerArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
|
||||||
|
/// enable authentication with predictable, empty key material.
|
||||||
|
fn parse_hmac_key(input: &str) -> Result<String, String> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err("HMAC key must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
Ok(input.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a duration whose zero is meaningful server policy.
|
/// 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" {
|
||||||
@@ -206,208 +257,3 @@ fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-server"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
ServerArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bound(args: &[&str]) -> ServerArgs {
|
|
||||||
let mut argv = vec!["--bind", "127.0.0.1:2112"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
parse(&argv).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bind_is_required_and_parses_both_families() {
|
|
||||||
assert!(
|
|
||||||
parse(&[]).is_err(),
|
|
||||||
"a server with no address serves nothing"
|
|
||||||
);
|
|
||||||
|
|
||||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
|
||||||
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
|
|
||||||
|
|
||||||
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
|
|
||||||
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
|
||||||
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
|
|
||||||
assert!(parse(&["--bind", "localhost:2112"]).is_err());
|
|
||||||
// Still one address per option: comma-separated syntax is not a thing
|
|
||||||
// here, and accepting it silently would be worse than rejecting it.
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bind_is_repeatable_and_keeps_the_requested_order() {
|
|
||||||
let args = parse(&[
|
|
||||||
"--bind",
|
|
||||||
"[::1]:2112",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2113",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
args.bind,
|
|
||||||
[
|
|
||||||
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
|
|
||||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
|
|
||||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
|
|
||||||
],
|
|
||||||
"listeners are served in the order they were requested"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
|
|
||||||
// Addresses never enter the server configuration: every listener is
|
|
||||||
// built from a clone of this one value.
|
|
||||||
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
|
||||||
.unwrap()
|
|
||||||
.server_config();
|
|
||||||
let several = parse(&[
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
"--bind",
|
|
||||||
"[::1]:2112",
|
|
||||||
"--max-sessions",
|
|
||||||
"4",
|
|
||||||
])
|
|
||||||
.unwrap()
|
|
||||||
.server_config();
|
|
||||||
|
|
||||||
assert_eq!(one, several);
|
|
||||||
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn omitted_policy_options_keep_the_library_defaults() {
|
|
||||||
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_options_map_onto_the_server_config() {
|
|
||||||
let config = bound(&[
|
|
||||||
"--hmac",
|
|
||||||
"secret",
|
|
||||||
"--max-sessions",
|
|
||||||
"512",
|
|
||||||
"--max-packet-length",
|
|
||||||
"1472",
|
|
||||||
"--min-interval",
|
|
||||||
"20ms",
|
|
||||||
"--burst",
|
|
||||||
"3",
|
|
||||||
"--idle-timeout",
|
|
||||||
"30s",
|
|
||||||
"--max-duration",
|
|
||||||
"2m",
|
|
||||||
])
|
|
||||||
.server_config();
|
|
||||||
|
|
||||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
|
||||||
assert_eq!(config.max_sessions(), 512);
|
|
||||||
assert_eq!(config.max_packet_length(), 1472);
|
|
||||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
|
||||||
assert_eq!(config.burst_allowance(), 3);
|
|
||||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
|
||||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_capability_restrictions_map_onto_the_server_config() {
|
|
||||||
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.timestamp_allowance(),
|
|
||||||
TimestampAllowance::Single,
|
|
||||||
"a request for both timestamps is negotiated to midpoint"
|
|
||||||
);
|
|
||||||
assert!(!config.dscp_allowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_timestamp_allowance_mode_parses() {
|
|
||||||
for (argument, expected) in [
|
|
||||||
("dual", TimestampAllowance::Dual),
|
|
||||||
("single", TimestampAllowance::Single),
|
|
||||||
("none", TimestampAllowance::None),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
bound(&["--timestamp-allowance", argument])
|
|
||||||
.server_config()
|
|
||||||
.timestamp_allowance(),
|
|
||||||
expected,
|
|
||||||
"--timestamp-allowance {argument}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(parse(&[
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
"--timestamp-allowance",
|
|
||||||
"midpoint"
|
|
||||||
])
|
|
||||||
.is_err());
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
|
|
||||||
// Explicitly, and not only through the whole-config comparison above:
|
|
||||||
// these are the two values that would silently change every existing
|
|
||||||
// deployment's negotiation if the CLI restated them.
|
|
||||||
let config = bound(&["--idle-timeout", "30s"]).server_config();
|
|
||||||
|
|
||||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
|
||||||
assert!(config.dscp_allowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_zero_policy_values_are_preserved() {
|
|
||||||
let config = bound(&[
|
|
||||||
"--min-interval",
|
|
||||||
"0",
|
|
||||||
"--idle-timeout",
|
|
||||||
"0ms",
|
|
||||||
"--burst",
|
|
||||||
"0",
|
|
||||||
"--max-sessions",
|
|
||||||
"0",
|
|
||||||
"--max-packet-length",
|
|
||||||
"0",
|
|
||||||
])
|
|
||||||
.server_config();
|
|
||||||
|
|
||||||
assert_eq!(config.min_send_interval(), Duration::ZERO);
|
|
||||||
assert_eq!(config.idle_timeout(), Duration::ZERO);
|
|
||||||
assert_eq!(config.burst_allowance(), 0);
|
|
||||||
assert_eq!(config.max_sessions(), 0);
|
|
||||||
assert_eq!(config.max_packet_length(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn durations_require_a_supported_unit() {
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
|
||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
|
||||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File renamed without changes.
@@ -0,0 +1,107 @@
|
|||||||
|
use std::{
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use irtt_server::{address_family_available, ServerSet, ServerSetError};
|
||||||
|
|
||||||
|
use super::args::ServerArgs;
|
||||||
|
|
||||||
|
/// How often the shutdown future samples the process-wide signal flag.
|
||||||
|
///
|
||||||
|
/// The flag a signal handler sets has no async wake mechanism, so the future
|
||||||
|
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
|
||||||
|
/// after that future becomes ready, including fanning it out to the listeners.
|
||||||
|
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
/// Runs the configured listeners until the process is asked to shut down.
|
||||||
|
///
|
||||||
|
/// The applet owns exactly one current-thread Tokio runtime and one
|
||||||
|
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
|
||||||
|
/// is an ordinary set of one and takes the same path as a set of several: one
|
||||||
|
/// construction, one shutdown future, one supervision rule. Listener
|
||||||
|
/// concurrency is async I/O concurrency and needs no worker threads.
|
||||||
|
pub fn run_server(
|
||||||
|
args: ServerArgs,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime.block_on(serve(args, shutdown_requested))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(
|
||||||
|
args: ServerArgs,
|
||||||
|
shutdown_requested: &AtomicBool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = args.server_config();
|
||||||
|
let binds = args.resolve_binds();
|
||||||
|
let is_implicit_default = args.bind.is_empty();
|
||||||
|
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||||
|
Ok(set) => set,
|
||||||
|
Err(ServerSetError::ListenerSetup { addr, source }) => {
|
||||||
|
let family_available = address_family_available(addr);
|
||||||
|
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
|
||||||
|
Some(remaining) => {
|
||||||
|
eprintln!(
|
||||||
|
"irtt-server: default listener {addr} unavailable ({source}); \
|
||||||
|
continuing with the remaining default listener"
|
||||||
|
);
|
||||||
|
// `remaining`'s lone survivor is bound exactly as it
|
||||||
|
// would be alone: the address family that just failed
|
||||||
|
// has no local support at all, so nothing on that family
|
||||||
|
// could be competing for the surviving listener's port.
|
||||||
|
ServerSet::bind(remaining, config).await?
|
||||||
|
}
|
||||||
|
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
// Only once every listener is up. Announcing one address and then failing
|
||||||
|
// to bind the next would report a service that never started.
|
||||||
|
for addr in set.local_addrs() {
|
||||||
|
eprintln!("irtt-server: listening on {addr}");
|
||||||
|
}
|
||||||
|
set.run(shutdown_signal(shutdown_requested)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a listener-setup failure for `failed_addr` should be retried with
|
||||||
|
/// a reduced bind set instead of failing the whole invocation, and if so,
|
||||||
|
/// what to retry with.
|
||||||
|
///
|
||||||
|
/// Only the implicit zero-argument default gets this treatment, and only
|
||||||
|
/// when `failed_addr`'s own address family has no local support on this host
|
||||||
|
/// at all (`family_available` is `false`) — not merely because its specific
|
||||||
|
/// port was unavailable for some other reason: already in use, permission
|
||||||
|
/// denied, no safe wildcard reply-source path, or anything else
|
||||||
|
/// `ServerSet::bind` can fail with. Those are real configuration or
|
||||||
|
/// environment problems this invocation should fail on, not silently work
|
||||||
|
/// around by dropping a listener the operator asked for. An explicit
|
||||||
|
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
|
||||||
|
/// regardless.
|
||||||
|
fn implicit_default_fallback(
|
||||||
|
is_implicit_default: bool,
|
||||||
|
family_available: bool,
|
||||||
|
original_binds: &[SocketAddr],
|
||||||
|
failed_addr: SocketAddr,
|
||||||
|
) -> Option<Vec<SocketAddr>> {
|
||||||
|
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let remaining: Vec<SocketAddr> = original_binds
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&addr| addr != failed_addr)
|
||||||
|
.collect();
|
||||||
|
(!remaining.is_empty()).then_some(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||||
|
while !shutdown_requested.load(Ordering::Relaxed) {
|
||||||
|
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::shared::client::{
|
||||||
|
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||||
|
ManagedRunSetup, TargetArg, TargetSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||||
|
pub struct TuiArgs {
|
||||||
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
value_name = "TARGET",
|
||||||
|
num_args = 1..,
|
||||||
|
required = true,
|
||||||
|
value_parser = parse_target,
|
||||||
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||||
|
)]
|
||||||
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
|
/// Managed group pacing for multi-target mode.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
default_value_t = GroupPacingArg::Staggered,
|
||||||
|
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||||
|
)]
|
||||||
|
pub pacing: GroupPacingArg,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "0",
|
||||||
|
value_parser = parse_test_duration,
|
||||||
|
help = "Test duration; use 0 for continuous mode",
|
||||||
|
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
||||||
|
)]
|
||||||
|
pub duration: Duration,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub common: CommonClientArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiArgs {
|
||||||
|
/// Validate the selected targets and prepare the managed run.
|
||||||
|
///
|
||||||
|
/// The TUI always requires a target: the parser rejects an empty target
|
||||||
|
/// list, and this rejects a target set that cannot be labelled uniquely.
|
||||||
|
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||||
|
prepare_managed_run(
|
||||||
|
&self.common,
|
||||||
|
self.duration,
|
||||||
|
TargetSelection {
|
||||||
|
targets: &self.targets,
|
||||||
|
pacing: self.pacing,
|
||||||
|
stdin_controlled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
self.duration == Duration::ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for TuiArgs {
|
||||||
|
type Target = CommonClientArgs;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -87,12 +87,18 @@ pub fn run_tui(
|
|||||||
) {
|
) {
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
}
|
}
|
||||||
|
let previous_dropped = dropped_events;
|
||||||
let drain_state = drain_tui_events(
|
let drain_state = drain_tui_events(
|
||||||
&mut events,
|
&mut events,
|
||||||
&mut state,
|
&mut state,
|
||||||
&mut terminal_targets,
|
&mut terminal_targets,
|
||||||
&mut dropped_events,
|
&mut dropped_events,
|
||||||
);
|
);
|
||||||
|
if dropped_events != previous_dropped {
|
||||||
|
// Data loss changes the meaning of displayed statistics, even while
|
||||||
|
// ordinary measurement refreshes are paused.
|
||||||
|
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||||
|
}
|
||||||
match drain_state {
|
match drain_state {
|
||||||
ManagedDrainState::Empty => {
|
ManagedDrainState::Empty => {
|
||||||
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
||||||
@@ -178,11 +184,16 @@ fn drain_tui_events(
|
|||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
terminal_targets: &mut HashSet<TargetInstance>,
|
||||||
dropped_events: &mut u64,
|
dropped_events: &mut u64,
|
||||||
) -> ManagedDrainState {
|
) -> ManagedDrainState {
|
||||||
drain_managed_events(events, dropped_events, |event| {
|
let previous_dropped = *dropped_events;
|
||||||
|
let drained = drain_managed_events(events, dropped_events, |event| {
|
||||||
process_tui_event(event, state, terminal_targets);
|
process_tui_event(event, state, terminal_targets);
|
||||||
Ok::<(), std::convert::Infallible>(())
|
Ok::<(), std::convert::Infallible>(())
|
||||||
})
|
})
|
||||||
.expect("processing TUI managed events is 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(
|
fn drain_final_tui_events(
|
||||||
@@ -301,3 +312,45 @@ fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
|
|||||||
};
|
};
|
||||||
render_wait.min(TUI_WAIT_SLICE)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
|||||||
|
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||||
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
|
use std::{env, ffi::OsString, process::ExitCode};
|
||||||
|
|
||||||
|
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
||||||
|
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||||
|
use crate::signal::install_signal_handler;
|
||||||
|
|
||||||
|
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
||||||
|
///
|
||||||
|
/// Chooses an applet by subcommand or by recognized argv0 and may reference
|
||||||
|
/// every applet enabled by the active feature set.
|
||||||
|
pub fn dispatcher_main() -> ExitCode {
|
||||||
|
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for the dedicated `irtt-client` binary.
|
||||||
|
///
|
||||||
|
/// Runs the stream client directly; it never consults argv0 or the applet
|
||||||
|
/// dispatcher, so a renamed or copied executable still runs the client.
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
pub fn client_main() -> ExitCode {
|
||||||
|
run_with_exit_code("irtt-client", || {
|
||||||
|
run_client_applet(env::args_os().collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for the dedicated `irtt-tui` binary.
|
||||||
|
///
|
||||||
|
/// Runs the terminal UI directly; it never consults argv0 or the applet
|
||||||
|
/// dispatcher, so a renamed or copied executable still runs the TUI.
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
pub fn tui_main() -> ExitCode {
|
||||||
|
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for the dedicated `irtt-server` binary.
|
||||||
|
///
|
||||||
|
/// Runs the server directly; it never consults argv0 or the applet
|
||||||
|
/// dispatcher, so a renamed or copied executable still runs the server.
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub fn server_main() -> ExitCode {
|
||||||
|
run_with_exit_code("irtt-server", || {
|
||||||
|
run_server_applet(env::args_os().collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with_exit_code(
|
||||||
|
program: &str,
|
||||||
|
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||||
|
) -> ExitCode {
|
||||||
|
match f() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{program}: {err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let argv: Vec<OsString> = env::args_os().collect();
|
||||||
|
let (requested, argv) = match dispatch_from_argv(argv)? {
|
||||||
|
AppletDispatch::Run { applet, argv } => (applet, argv),
|
||||||
|
AppletDispatch::Help(help) => {
|
||||||
|
print!("{help}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match requested {
|
||||||
|
RequestedApplet::Client => run_client_applet(argv),
|
||||||
|
RequestedApplet::Tui => run_tui_applet(argv),
|
||||||
|
RequestedApplet::Server => run_server_applet(argv),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the shutdown signal handler shared by every applet and hands the
|
||||||
|
/// resulting flag to `f`.
|
||||||
|
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||||
|
fn with_shutdown_flag(
|
||||||
|
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||||
|
install_signal_handler(Arc::clone(&shutdown_requested))
|
||||||
|
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||||
|
f(shutdown_requested.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
with_shutdown_flag(|shutdown_requested| {
|
||||||
|
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||||
|
crate::cmd::client::run_stream(args, shutdown_requested)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "client"))]
|
||||||
|
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Err("client applet is not available; rebuild with the client feature".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
with_shutdown_flag(|shutdown_requested| {
|
||||||
|
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||||
|
crate::cmd::server::run_server(args, shutdown_requested)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Err("server applet is not available; rebuild with the server feature".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
with_shutdown_flag(|shutdown_requested| {
|
||||||
|
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||||
|
crate::cmd::tui::run_tui(args, shutdown_requested)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||||
|
}
|
||||||
File renamed without changes.
+27
-3
@@ -1,10 +1,10 @@
|
|||||||
use std::time::Duration;
|
use std::{fmt, 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};
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||||
|
|
||||||
#[derive(Debug, Clone, clap::Args)]
|
#[derive(Clone, clap::Args)]
|
||||||
pub struct CommonClientArgs {
|
pub struct CommonClientArgs {
|
||||||
/// Probe interval.
|
/// Probe interval.
|
||||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||||
@@ -15,7 +15,7 @@ pub struct CommonClientArgs {
|
|||||||
pub length: u32,
|
pub length: u32,
|
||||||
|
|
||||||
/// HMAC key.
|
/// HMAC key.
|
||||||
#[arg(long)]
|
#[arg(long, value_parser = parse_hmac_key)]
|
||||||
pub hmac: Option<String>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
/// Clock mode to request.
|
/// Clock mode to request.
|
||||||
@@ -63,6 +63,23 @@ pub struct CommonClientArgs {
|
|||||||
pub loose: bool,
|
pub loose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for CommonClientArgs {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("CommonClientArgs")
|
||||||
|
.field("interval", &self.interval)
|
||||||
|
.field("length", &self.length)
|
||||||
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
|
.field("clock", &self.clock)
|
||||||
|
.field("tstamp", &self.tstamp)
|
||||||
|
.field("stats", &self.stats)
|
||||||
|
.field("server_fill", &self.server_fill)
|
||||||
|
.field("dscp", &self.dscp)
|
||||||
|
.field("ttl", &self.ttl)
|
||||||
|
.field("loose", &self.loose)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
pub enum ClockArg {
|
pub enum ClockArg {
|
||||||
Wall,
|
Wall,
|
||||||
@@ -189,6 +206,13 @@ 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());
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use irtt_client::{
|
||||||
|
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
||||||
|
SocketConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::args::CommonClientArgs;
|
||||||
|
|
||||||
|
impl CommonClientArgs {
|
||||||
|
/// Build the shared client configuration template for a managed run.
|
||||||
|
///
|
||||||
|
/// The config is endpoint-independent; managed targets own their addresses.
|
||||||
|
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||||
|
ClientConfig {
|
||||||
|
open: OpenPolicy {
|
||||||
|
negotiation: if self.loose {
|
||||||
|
NegotiationPolicy::Loose
|
||||||
|
} else {
|
||||||
|
NegotiationPolicy::Strict
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
request: SessionRequest {
|
||||||
|
duration: (!duration.is_zero()).then_some(duration),
|
||||||
|
interval: self.interval,
|
||||||
|
length: self.length,
|
||||||
|
received_stats: self.stats.into(),
|
||||||
|
stamp_at: self.tstamp.into(),
|
||||||
|
clock: self.clock.into(),
|
||||||
|
dscp: self.dscp,
|
||||||
|
server_fill: self.server_fill.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
auth: self
|
||||||
|
.hmac
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Authentication::Unauthenticated, |key| {
|
||||||
|
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
||||||
|
}),
|
||||||
|
socket: SocketConfig {
|
||||||
|
ttl: self.ttl,
|
||||||
|
..SocketConfig::default()
|
||||||
|
},
|
||||||
|
..ClientConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
|
||||||
|
let interval_nanos = interval.as_nanos();
|
||||||
|
if interval_nanos == 0 {
|
||||||
|
return u64::MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = duration
|
||||||
|
.as_nanos()
|
||||||
|
.saturating_add(interval_nanos.saturating_sub(1))
|
||||||
|
/ interval_nanos;
|
||||||
|
expected.min(u128::from(u64::MAX)) as u64
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
pub mod args;
|
||||||
|
pub mod config;
|
||||||
|
pub mod prepare;
|
||||||
|
pub mod session;
|
||||||
|
pub mod targets;
|
||||||
|
|
||||||
|
pub use args::{
|
||||||
|
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
||||||
|
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||||
|
};
|
||||||
|
pub use config::expected_probe_count;
|
||||||
|
pub use prepare::{
|
||||||
|
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||||
|
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
|
};
|
||||||
|
pub use session::is_shutdown_requested;
|
||||||
|
pub use targets::{
|
||||||
|
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||||
|
PreparedTarget, TargetArg, TargetSpec,
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! One preparation step from parsed arguments to a runnable managed setup.
|
||||||
|
//!
|
||||||
|
//! Both client applets accept the same target and pacing arguments and then
|
||||||
|
//! have to turn them into the same three things: a validated target set, one
|
||||||
|
//! shared [`ClientConfig`] template, and a [`ManagedPacing`]. Doing that
|
||||||
|
//! separately in each applet is how the CLI ended up deriving its client
|
||||||
|
//! configuration from an arbitrary "primary" target whose address the managed
|
||||||
|
//! driver immediately replaced per target.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use irtt_client::{
|
||||||
|
managed::{ManagedClientConfig, ManagedCompletionPolicy, ManagedPacing},
|
||||||
|
ClientConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
args::CommonClientArgs,
|
||||||
|
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
||||||
|
PreparedTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Capacity of the lossy managed presentation event channel.
|
||||||
|
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||||
|
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
|
||||||
|
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
|
||||||
|
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||||
|
|
||||||
|
/// Target and pacing arguments common to the client applets.
|
||||||
|
///
|
||||||
|
/// Duration is deliberately absent: the applets disagree about what a duration
|
||||||
|
/// means and what its default is, so each keeps its own.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TargetSelection<'a> {
|
||||||
|
pub targets: &'a [TargetArg],
|
||||||
|
pub pacing: GroupPacingArg,
|
||||||
|
pub stdin_controlled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a managed run needs, prepared and validated together.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ManagedRunSetup {
|
||||||
|
/// Validated targets, in the order the user gave them.
|
||||||
|
pub targets: Vec<PreparedTarget>,
|
||||||
|
/// Shared client configuration template.
|
||||||
|
///
|
||||||
|
/// The managed driver supplies each target's address and authentication
|
||||||
|
/// from its [`ManagedTargetConfig`](irtt_client::managed::ManagedTargetConfig),
|
||||||
|
/// so this template carries no target of its own.
|
||||||
|
pub client: ClientConfig,
|
||||||
|
/// Send coordination across active targets.
|
||||||
|
pub pacing: ManagedPacing,
|
||||||
|
/// Whether this stream may replace its desired set from stdin.
|
||||||
|
pub stdin_controlled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedRunSetup {
|
||||||
|
/// Number of validated targets.
|
||||||
|
pub fn target_count(&self) -> usize {
|
||||||
|
self.targets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this run drives more than one target.
|
||||||
|
pub fn is_multi_target(&self) -> bool {
|
||||||
|
self.targets.len() > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Managed target configurations, in argument order.
|
||||||
|
pub fn managed_targets(&self) -> Vec<irtt_client::managed::ManagedTargetConfig> {
|
||||||
|
self.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.managed.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Managed driver configuration for this run.
|
||||||
|
pub fn managed_config(&self) -> ManagedClientConfig {
|
||||||
|
let target_count = self.target_count();
|
||||||
|
let mut config = ManagedClientConfig {
|
||||||
|
client: self.client.clone(),
|
||||||
|
pacing: self.pacing,
|
||||||
|
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||||
|
event_capacity: MANAGED_EVENT_CAPACITY,
|
||||||
|
outcome_history_limit: target_count,
|
||||||
|
max_live_target_generations: target_count,
|
||||||
|
..ManagedClientConfig::default()
|
||||||
|
};
|
||||||
|
if self.stdin_controlled {
|
||||||
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
|
||||||
|
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the selected targets and build the shared run setup.
|
||||||
|
///
|
||||||
|
/// Targets are validated before any configuration is built, so a bad target set
|
||||||
|
/// fails before the caller can act on a half-prepared run.
|
||||||
|
pub fn prepare_managed_run(
|
||||||
|
common: &CommonClientArgs,
|
||||||
|
duration: Duration,
|
||||||
|
selection: TargetSelection<'_>,
|
||||||
|
) -> Result<ManagedRunSetup, String> {
|
||||||
|
let specs = if selection.stdin_controlled {
|
||||||
|
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
|
||||||
|
return Err(format!(
|
||||||
|
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
super::targets::target_specs_with_empty(selection.targets, true)?
|
||||||
|
} else {
|
||||||
|
target_specs(selection.targets)?
|
||||||
|
};
|
||||||
|
let targets = prepare_managed_targets(specs)?;
|
||||||
|
Ok(ManagedRunSetup {
|
||||||
|
targets,
|
||||||
|
client: common.to_client_config(duration),
|
||||||
|
pacing: selection.pacing.into(),
|
||||||
|
stdin_controlled: selection.stdin_controlled,
|
||||||
|
})
|
||||||
|
}
|
||||||
-58
@@ -81,61 +81,3 @@ pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
|||||||
*stop_requested = true;
|
*stop_requested = true;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
|
||||||
assert_eq!(peer_close_run_error(false, false, 1), None);
|
|
||||||
assert_eq!(peer_close_run_error(true, false, 0), None);
|
|
||||||
assert_eq!(peer_close_run_error(true, true, 1), None);
|
|
||||||
assert_eq!(
|
|
||||||
peer_close_run_error(true, false, 1).as_deref(),
|
|
||||||
Some("continuous run ended because of peer closure (1 target session)")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
peer_close_run_error(true, false, 3).as_deref(),
|
|
||||||
Some("continuous run ended because of peer closure (3 target sessions)")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
|
|
||||||
let mut stop_requested = false;
|
|
||||||
assert!(!request_managed_stop_for_peer_close(
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
1,
|
|
||||||
&mut stop_requested,
|
|
||||||
));
|
|
||||||
assert!(!request_managed_stop_for_peer_close(
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
1,
|
|
||||||
&mut stop_requested,
|
|
||||||
));
|
|
||||||
assert!(!request_managed_stop_for_peer_close(
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
&mut stop_requested,
|
|
||||||
));
|
|
||||||
assert!(!stop_requested);
|
|
||||||
|
|
||||||
assert!(request_managed_stop_for_peer_close(
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
1,
|
|
||||||
&mut stop_requested,
|
|
||||||
));
|
|
||||||
assert!(stop_requested);
|
|
||||||
assert!(!request_managed_stop_for_peer_close(
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
1,
|
|
||||||
&mut stop_requested,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
use std::{collections::HashSet, fmt};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use irtt_client::{
|
||||||
|
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||||
|
Authentication, HmacKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One raw positional target captured by Clap.
|
||||||
|
///
|
||||||
|
/// Parsing happens during run preparation so invalid target syntax never makes
|
||||||
|
/// Clap include the original argument (which may carry an HMAC key) in an
|
||||||
|
/// error diagnostic.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct TargetArg {
|
||||||
|
input: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetArg {
|
||||||
|
pub fn new(input: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
input: input.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TargetArg {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("TargetArg(<redacted>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture one positional target without exposing it through Clap diagnostics.
|
||||||
|
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||||
|
Ok(TargetArg::new(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct TargetSpec {
|
||||||
|
pub label: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub auth: TargetAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TargetSpec {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("TargetSpec")
|
||||||
|
.field("label", &self.label)
|
||||||
|
.field("addr", &self.addr)
|
||||||
|
.field("auth", &self.auth)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PreparedTarget {
|
||||||
|
pub label: String,
|
||||||
|
pub managed: ManagedTargetConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for PreparedTarget {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("PreparedTarget")
|
||||||
|
.field("label", &self.label)
|
||||||
|
.field("server_addr", &self.managed.server_addr)
|
||||||
|
.field("auth", &self.managed.auth)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum GroupPacingArg {
|
||||||
|
Staggered,
|
||||||
|
Burst,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GroupPacingArg> for ManagedPacing {
|
||||||
|
fn from(value: GroupPacingArg) -> Self {
|
||||||
|
match value {
|
||||||
|
GroupPacingArg::Staggered => Self::Staggered,
|
||||||
|
GroupPacingArg::Burst => Self::Burst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TargetParseError {
|
||||||
|
EmptyLabel,
|
||||||
|
EmptyAddress,
|
||||||
|
InvalidEscape,
|
||||||
|
EmptyTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TargetParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::EmptyLabel => "target label must not be empty",
|
||||||
|
Self::EmptyAddress => "target address must not be empty",
|
||||||
|
Self::InvalidEscape => "invalid target escape sequence",
|
||||||
|
Self::EmptyTarget => "target must not be empty",
|
||||||
|
};
|
||||||
|
f.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == delimiter {
|
||||||
|
parts.push(&input[start..index]);
|
||||||
|
start = index + ch.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
parts.push(&input[start..]);
|
||||||
|
Ok(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == delimiter {
|
||||||
|
return Ok(Some(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(input: &str) -> Result<String, TargetParseError> {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut escaped = false;
|
||||||
|
for ch in input.chars() {
|
||||||
|
if escaped {
|
||||||
|
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else {
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if input[index..].starts_with("@hmac=") {
|
||||||
|
return Ok(Some(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_target_syntax(
|
||||||
|
input: &str,
|
||||||
|
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err(TargetParseError::EmptyTarget);
|
||||||
|
}
|
||||||
|
let (main, auth) = match hmac_modifier(input)? {
|
||||||
|
Some(index) => {
|
||||||
|
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||||
|
let auth = if value.is_empty() {
|
||||||
|
TargetAuth::Override(Authentication::Unauthenticated)
|
||||||
|
} else {
|
||||||
|
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
||||||
|
};
|
||||||
|
(&input[..index], auth)
|
||||||
|
}
|
||||||
|
None => (input, TargetAuth::Inherit),
|
||||||
|
};
|
||||||
|
let (label, address) = match first_unescaped(main, '=')? {
|
||||||
|
None => (None, unescape(main)?),
|
||||||
|
Some(index) => {
|
||||||
|
let label = unescape(&main[..index])?;
|
||||||
|
let address = unescape(&main[index + '='.len_utf8()..])?;
|
||||||
|
(Some(label), address)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if label.as_deref().is_some_and(str::is_empty) {
|
||||||
|
return Err(TargetParseError::EmptyLabel);
|
||||||
|
}
|
||||||
|
if address.is_empty() {
|
||||||
|
return Err(TargetParseError::EmptyAddress);
|
||||||
|
}
|
||||||
|
Ok((label, address, auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn target_specs_with_empty(
|
||||||
|
targets: &[TargetArg],
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
|
let mut specs = Vec::with_capacity(targets.len());
|
||||||
|
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
|
||||||
|
for (index, target) in targets.iter().enumerate() {
|
||||||
|
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
|
||||||
|
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
|
||||||
|
let label = match explicit_label {
|
||||||
|
Some(label) => label,
|
||||||
|
None => {
|
||||||
|
let count = unlabeled_counts.entry(addr.clone()).or_default();
|
||||||
|
*count += 1;
|
||||||
|
if *count == 1 {
|
||||||
|
addr.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}#{}", addr, *count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
specs.push(TargetSpec { label, addr, auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
if specs.is_empty() && !allow_empty {
|
||||||
|
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut labels = HashSet::new();
|
||||||
|
for spec in &specs {
|
||||||
|
if !labels.insert(spec.label.clone()) {
|
||||||
|
return Err("duplicate target label".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||||
|
target_specs_with_empty(targets, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||||
|
let mut targets = Vec::with_capacity(specs.len());
|
||||||
|
for spec in specs {
|
||||||
|
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||||
|
managed.auth = spec.auth;
|
||||||
|
targets.push(PreparedTarget {
|
||||||
|
label: spec.label,
|
||||||
|
managed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one complete stdin target set after its line terminator was removed.
|
||||||
|
///
|
||||||
|
/// Commas frame stdin elements only; positional target arguments do not use
|
||||||
|
/// this framing and may therefore contain raw commas.
|
||||||
|
pub fn parse_stdin_target_set(
|
||||||
|
record: &str,
|
||||||
|
maximum_targets: usize,
|
||||||
|
) -> Result<Vec<PreparedTarget>, String> {
|
||||||
|
if record == "[]" {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
|
||||||
|
if elements.len() > maximum_targets {
|
||||||
|
return Err(format!(
|
||||||
|
"target set exceeds the {maximum_targets}-target limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||||
|
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,487 @@
|
|||||||
|
#[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\"")));
|
||||||
|
}
|
||||||
+4
-4
@@ -17,7 +17,7 @@ pub struct InTreeServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InTreeServer {
|
impl InTreeServer {
|
||||||
pub fn start() -> Self {
|
pub fn start(config: ServerConfig) -> Self {
|
||||||
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||||
let done = thread::spawn(move || {
|
let done = thread::spawn(move || {
|
||||||
@@ -27,7 +27,7 @@ impl InTreeServer {
|
|||||||
.expect("failed to build in-tree test server runtime");
|
.expect("failed to build in-tree test server runtime");
|
||||||
runtime.block_on(async move {
|
runtime.block_on(async move {
|
||||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||||
let mut server = Server::bind(bind_addr, ServerConfig::default()).await?;
|
let mut server = Server::bind(bind_addr, config).await?;
|
||||||
startup_tx
|
startup_tx
|
||||||
.send(server.local_addr()?)
|
.send(server.local_addr()?)
|
||||||
.expect("test stopped waiting for in-tree server startup");
|
.expect("test stopped waiting for in-tree server startup");
|
||||||
@@ -55,7 +55,7 @@ impl InTreeServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) {
|
fn stop_inner(&mut self) {
|
||||||
if let Some(shutdown) = self.shutdown.take() {
|
if let Some(shutdown) = self.shutdown.take() {
|
||||||
let _ = shutdown.send(());
|
let _ = shutdown.send(());
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,6 @@ impl InTreeServer {
|
|||||||
|
|
||||||
impl Drop for InTreeServer {
|
impl Drop for InTreeServer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.stop();
|
self.stop_inner();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
use std::{ffi::OsString, path::Path};
|
|
||||||
|
|
||||||
use clap::Command;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum RequestedApplet {
|
|
||||||
Client,
|
|
||||||
Tui,
|
|
||||||
Server,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum AppletDispatch {
|
|
||||||
Run {
|
|
||||||
applet: RequestedApplet,
|
|
||||||
argv: Vec<OsString>,
|
|
||||||
},
|
|
||||||
Help(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
|
||||||
match applet_basename(argv0) {
|
|
||||||
"irtt-cli" => Some(RequestedApplet::Client),
|
|
||||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
|
||||||
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
|
||||||
"irtt-rs" => None,
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
|
|
||||||
let argv0 = argv
|
|
||||||
.first()
|
|
||||||
.and_then(|arg| arg.to_str())
|
|
||||||
.unwrap_or("irtt-rs");
|
|
||||||
|
|
||||||
if let Some(applet) = detect_applet_from_argv0(argv0) {
|
|
||||||
return Ok(AppletDispatch::Run { applet, argv });
|
|
||||||
}
|
|
||||||
|
|
||||||
let basename = applet_basename(argv0);
|
|
||||||
if basename.starts_with("irtt-") && basename != "irtt-rs" {
|
|
||||||
return Err(format!(
|
|
||||||
"unknown applet name '{basename}'. Known applet names: {}",
|
|
||||||
known_applet_binary_names()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
|
|
||||||
return Err(format!(
|
|
||||||
"choose an applet: {}\n\n{}",
|
|
||||||
applet_command_names(),
|
|
||||||
dispatcher_help()
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
match command {
|
|
||||||
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
|
||||||
"client" => Ok(AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Client,
|
|
||||||
argv: applet_argv("irtt-cli", &argv[2..]),
|
|
||||||
}),
|
|
||||||
"tui" => Ok(AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Tui,
|
|
||||||
argv: applet_argv("irtt-tui", &argv[2..]),
|
|
||||||
}),
|
|
||||||
"server" => Ok(AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Server,
|
|
||||||
argv: applet_argv("irtt-server", &argv[2..]),
|
|
||||||
}),
|
|
||||||
_ => Err(format!(
|
|
||||||
"unknown applet '{command}'. Choose one of: {}\n\n{}",
|
|
||||||
applet_command_names(),
|
|
||||||
dispatcher_help()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dispatcher_help() -> String {
|
|
||||||
let mut command = Command::new("irtt-rs")
|
|
||||||
.about("IRTT-compatible multi-applet dispatcher")
|
|
||||||
.subcommand(Command::new("client").about(client_applet_about()))
|
|
||||||
.subcommand(Command::new("tui").about(tui_applet_about()))
|
|
||||||
.subcommand(Command::new("server").about(server_applet_about()))
|
|
||||||
.after_help(dispatcher_after_help());
|
|
||||||
command.render_help().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn client_applet_about() -> &'static str {
|
|
||||||
if cfg!(feature = "client") {
|
|
||||||
"Run the stream client applet"
|
|
||||||
} else {
|
|
||||||
"Stream client applet is not available in this build"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tui_applet_about() -> &'static str {
|
|
||||||
if cfg!(feature = "tui") {
|
|
||||||
"Run the terminal UI applet"
|
|
||||||
} else {
|
|
||||||
"Terminal UI applet is not available in this build"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn server_applet_about() -> &'static str {
|
|
||||||
if cfg!(feature = "server") {
|
|
||||||
"Run the UDP server applet"
|
|
||||||
} else {
|
|
||||||
"UDP server applet is not available in this build"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn known_applet_binary_names() -> &'static str {
|
|
||||||
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn applet_command_names() -> &'static str {
|
|
||||||
"client, tui, or server"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatcher_after_help() -> String {
|
|
||||||
let mut help = "Recognized applet binary names: irtt-cli, irtt-tui, irtt-server".to_owned();
|
|
||||||
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
if !cfg!(feature = "client") {
|
|
||||||
missing.push("irtt-cli requires the client feature");
|
|
||||||
}
|
|
||||||
if !cfg!(feature = "server") {
|
|
||||||
missing.push("irtt-server requires the server feature");
|
|
||||||
}
|
|
||||||
if !cfg!(feature = "tui") {
|
|
||||||
missing.push("irtt-tui requires the tui feature");
|
|
||||||
}
|
|
||||||
|
|
||||||
match missing.len() {
|
|
||||||
0 => {}
|
|
||||||
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
|
|
||||||
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
|
|
||||||
}
|
|
||||||
help
|
|
||||||
}
|
|
||||||
|
|
||||||
fn applet_basename(argv0: &str) -> &str {
|
|
||||||
let name = Path::new(argv0)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(argv0);
|
|
||||||
strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the platform executable suffix from a file name.
|
|
||||||
///
|
|
||||||
/// The suffix is part of the file name on Windows and empty everywhere else, so
|
|
||||||
/// this is a no-op on Unix. Without it, every applet name — including the
|
|
||||||
/// `irtt-rs` dispatcher itself — reaches argv0 detection as `<name>.exe` and is
|
|
||||||
/// rejected as an unknown applet. The applet names themselves are still matched
|
|
||||||
/// exactly; only this platform artifact is case-insensitive, because it is not
|
|
||||||
/// a name the user chose.
|
|
||||||
///
|
|
||||||
/// The suffix is a parameter so that Windows behavior is assertable from any
|
|
||||||
/// host, not only where `EXE_SUFFIX` happens to be non-empty.
|
|
||||||
fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str {
|
|
||||||
if suffix.is_empty() || name.len() <= suffix.len() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
let split = name.len() - suffix.len();
|
|
||||||
let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else {
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
if tail.eq_ignore_ascii_case(suffix) {
|
|
||||||
stem
|
|
||||||
} else {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
|
||||||
let mut argv = Vec::with_capacity(args.len() + 1);
|
|
||||||
argv.push(OsString::from(applet_name));
|
|
||||||
argv.extend(args.iter().cloned());
|
|
||||||
argv
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn applet_detection_maps_binary_names() {
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("/usr/bin/irtt-cli"),
|
|
||||||
Some(RequestedApplet::Client)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("irtt-tui"),
|
|
||||||
Some(RequestedApplet::Tui)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0("irtt-server"),
|
|
||||||
Some(RequestedApplet::Server)
|
|
||||||
);
|
|
||||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
|
||||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
|
||||||
let exe = std::env::consts::EXE_SUFFIX;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
|
|
||||||
Some(RequestedApplet::Server)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
detect_applet_from_argv0(&format!("irtt-cli{exe}")),
|
|
||||||
Some(RequestedApplet::Client)
|
|
||||||
);
|
|
||||||
|
|
||||||
// The dispatcher's own name must not be taken for an unknown applet.
|
|
||||||
let dispatcher = format!("irtt-rs{exe}");
|
|
||||||
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
|
|
||||||
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
|
|
||||||
|
|
||||||
// A name that merely ends in the suffix letters keeps them.
|
|
||||||
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
|
|
||||||
assert_eq!(
|
|
||||||
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
|
|
||||||
"irtt-server"
|
|
||||||
);
|
|
||||||
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
|
|
||||||
assert_eq!(
|
|
||||||
strip_suffix_ignoring_case("irtt-server", ".exe"),
|
|
||||||
"irtt-server"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
|
|
||||||
"irtt-serverexe"
|
|
||||||
);
|
|
||||||
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
|
|
||||||
assert_eq!(
|
|
||||||
strip_suffix_ignoring_case("irtt-server.exe", ""),
|
|
||||||
"irtt-server.exe"
|
|
||||||
);
|
|
||||||
// A multi-byte tail must not split mid-character.
|
|
||||||
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_help_renders_dispatcher_help() {
|
|
||||||
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
|
||||||
let AppletDispatch::Help(help) = dispatch else {
|
|
||||||
panic!("expected dispatcher help");
|
|
||||||
};
|
|
||||||
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
|
||||||
assert!(help.contains("client"));
|
|
||||||
if cfg!(feature = "tui") {
|
|
||||||
assert!(help.contains("Run the terminal UI applet"));
|
|
||||||
} else {
|
|
||||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
|
||||||
}
|
|
||||||
assert!(help.contains("server"));
|
|
||||||
if cfg!(feature = "server") {
|
|
||||||
assert!(help.contains("Run the UDP server applet"));
|
|
||||||
} else {
|
|
||||||
assert!(help.contains("UDP server applet is not available in this build"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_without_applet_is_an_error() {
|
|
||||||
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
|
||||||
assert!(err.contains("choose an applet"));
|
|
||||||
assert!(err.contains("client"));
|
|
||||||
if cfg!(feature = "tui") {
|
|
||||||
assert!(err.contains("tui"));
|
|
||||||
}
|
|
||||||
assert!(err.contains("server"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_subcommands_dispatch_to_applet_argv() {
|
|
||||||
assert_eq!(
|
|
||||||
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
|
||||||
.unwrap(),
|
|
||||||
AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Client,
|
|
||||||
argv: vec!["irtt-cli".into(), "host:2112".into()],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
|
||||||
AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Tui,
|
|
||||||
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
|
||||||
AppletDispatch::Run {
|
|
||||||
applet: RequestedApplet::Server,
|
|
||||||
argv: vec!["irtt-server".into()],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_irtt_binary_name_is_an_error() {
|
|
||||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
|
||||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
|
||||||
assert!(err.contains("irtt-cli"));
|
|
||||||
if cfg!(feature = "tui") {
|
|
||||||
assert!(err.contains("irtt-tui"));
|
|
||||||
}
|
|
||||||
assert!(err.contains("irtt-server"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::shared::client::{
|
|
||||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
|
||||||
ManagedRunSetup, TargetArg, TargetSelection, TimestampArg,
|
|
||||||
};
|
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
|
|
||||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
|
||||||
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
|
|
||||||
pub struct ClientArgs {
|
|
||||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
|
||||||
#[arg(
|
|
||||||
value_name = "TARGET",
|
|
||||||
num_args = 0..,
|
|
||||||
value_parser = parse_target,
|
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-cli host-a:2112 host-b:2112\n irtt-cli eu=host-a:2112 us=host-b:2112\n irtt-cli host-a:2112 eu=host-b:2112"
|
|
||||||
)]
|
|
||||||
pub targets: Vec<TargetArg>,
|
|
||||||
|
|
||||||
/// Managed group pacing for multi-target mode.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_enum,
|
|
||||||
default_value_t = GroupPacingArg::Staggered,
|
|
||||||
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
|
||||||
)]
|
|
||||||
pub pacing: GroupPacingArg,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
default_value = "10s",
|
|
||||||
value_parser = parse_test_duration,
|
|
||||||
help = "Test duration; use 0 for continuous mode",
|
|
||||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
|
|
||||||
)]
|
|
||||||
pub duration: Duration,
|
|
||||||
|
|
||||||
#[command(flatten)]
|
|
||||||
pub common: CommonClientArgs,
|
|
||||||
|
|
||||||
/// Event row output format.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_enum,
|
|
||||||
default_value_t = OutputFormat::Table,
|
|
||||||
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
|
||||||
)]
|
|
||||||
pub format: OutputFormat,
|
|
||||||
|
|
||||||
/// Comma-separated event row columns, or default/all.
|
|
||||||
#[arg(
|
|
||||||
short = 'c',
|
|
||||||
long,
|
|
||||||
value_name = "COLUMNS",
|
|
||||||
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
|
|
||||||
)]
|
|
||||||
pub columns: Option<String>,
|
|
||||||
|
|
||||||
/// List available event row columns and aliases, then exit.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
|
|
||||||
)]
|
|
||||||
pub list_columns: bool,
|
|
||||||
|
|
||||||
/// Header policy for table, CSV, and TSV output.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_enum,
|
|
||||||
default_value_t = HeaderMode::Auto,
|
|
||||||
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
|
|
||||||
)]
|
|
||||||
pub header: HeaderMode,
|
|
||||||
|
|
||||||
/// Include extra fields in table output and final summaries.
|
|
||||||
#[arg(long)]
|
|
||||||
pub verbose: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientArgs {
|
|
||||||
/// Validate the selected targets and prepare the managed run.
|
|
||||||
///
|
|
||||||
/// Targets are only required here. `--list-columns` returns before this is
|
|
||||||
/// called, so listing columns still needs no target.
|
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
|
||||||
prepare_managed_run(
|
|
||||||
&self.common,
|
|
||||||
self.duration,
|
|
||||||
TargetSelection {
|
|
||||||
targets: &self.targets,
|
|
||||||
pacing: self.pacing,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
|
||||||
self.duration == Duration::ZERO
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
|
||||||
self.common.tstamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for ClientArgs {
|
|
||||||
type Target = CommonClientArgs;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.common
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
||||||
pub enum OutputFormat {
|
|
||||||
/// Readable terminal table output.
|
|
||||||
Table,
|
|
||||||
/// Comma-separated event rows.
|
|
||||||
Csv,
|
|
||||||
/// Tab-separated event rows.
|
|
||||||
Tsv,
|
|
||||||
/// One JSON object per event row.
|
|
||||||
Jsonl,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OutputFormat {
|
|
||||||
pub fn prints_summary(self) -> bool {
|
|
||||||
matches!(self, Self::Table)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
||||||
pub enum HeaderMode {
|
|
||||||
/// Print headers for table, CSV, and TSV output.
|
|
||||||
Auto,
|
|
||||||
/// Always print headers where the format supports them.
|
|
||||||
Always,
|
|
||||||
/// Never print headers.
|
|
||||||
Never,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use clap::{CommandFactory, Parser};
|
|
||||||
use irtt_client::NegotiationPolicy;
|
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-cli"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
ClientArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_parser_keeps_finite_default_duration() {
|
|
||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(args.targets.len(), 1);
|
|
||||||
assert_eq!(args.targets[0].label, None);
|
|
||||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
|
||||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
|
||||||
assert_eq!(args.interval, Duration::from_secs(1));
|
|
||||||
assert_eq!(args.format, OutputFormat::Table);
|
|
||||||
assert_eq!(args.columns, None);
|
|
||||||
assert_eq!(args.header, HeaderMode::Auto);
|
|
||||||
assert!(!args.is_continuous());
|
|
||||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
|
||||||
|
|
||||||
let config = args.prepare().unwrap().client;
|
|
||||||
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
|
|
||||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
|
||||||
assert_eq!(config.stamp_at, StampAt::Both);
|
|
||||||
assert_eq!(config.clock, Clock::Both);
|
|
||||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
|
|
||||||
for format in ["table", "csv", "tsv", "jsonl"] {
|
|
||||||
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
|
|
||||||
}
|
|
||||||
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_columns_does_not_require_server() {
|
|
||||||
let args = parse(&["--list-columns"]).unwrap();
|
|
||||||
assert!(args.targets.is_empty());
|
|
||||||
assert!(args.list_columns);
|
|
||||||
// The parser accepts no target; preparation is where a target becomes
|
|
||||||
// mandatory, and the stream command returns before preparing when
|
|
||||||
// listing columns.
|
|
||||||
let err = args.prepare().unwrap_err();
|
|
||||||
assert!(err.contains("--list-columns"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multiple_positional_targets_parse_as_targets() {
|
|
||||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
|
||||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
|
||||||
assert_eq!(specs[1].label, "host-b:2112");
|
|
||||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_labeled_targets_parse() {
|
|
||||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "ams");
|
|
||||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
|
||||||
assert_eq!(specs[1].label, "sg");
|
|
||||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
|
|
||||||
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
|
|
||||||
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_labels_are_rejected() {
|
|
||||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
|
||||||
let err = args.prepare().unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("duplicate target label"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
|
||||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
|
||||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_target_endpoints_are_allowed() {
|
|
||||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
|
||||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_labelled_target_syntax_is_rejected() {
|
|
||||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
|
||||||
assert!(parse(&["label="]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn old_target_option_is_rejected() {
|
|
||||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pacing_option_accepts_supported_values() {
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
|
||||||
.unwrap()
|
|
||||||
.pacing,
|
|
||||||
GroupPacingArg::Staggered
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
|
||||||
.unwrap()
|
|
||||||
.pacing,
|
|
||||||
GroupPacingArg::Burst
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_help_lists_shared_protocol_options() {
|
|
||||||
let help = ClientArgs::command().render_help().to_string();
|
|
||||||
assert!(help.contains("--format <FORMAT>"));
|
|
||||||
assert!(!help.contains("--target "));
|
|
||||||
assert!(help.contains("--pacing <PACING>"));
|
|
||||||
assert!(help.contains("--columns <COLUMNS>"));
|
|
||||||
assert!(help.contains("--list-columns"));
|
|
||||||
assert!(help.contains("--header <HEADER>"));
|
|
||||||
assert!(help.contains("--tstamp <MODE>"));
|
|
||||||
assert!(help.contains("--stats <STATS>"));
|
|
||||||
assert!(help.contains("--sfill <STRING>"));
|
|
||||||
assert!(help.contains("--dscp <0..=63>"));
|
|
||||||
assert!(help.contains("--ttl <1..=255>"));
|
|
||||||
assert!(help.contains("--loose"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_format_summary_policy_is_table_only() {
|
|
||||||
assert!(OutputFormat::Table.prints_summary());
|
|
||||||
assert!(!OutputFormat::Csv.prints_summary());
|
|
||||||
assert!(!OutputFormat::Tsv.prints_summary());
|
|
||||||
assert!(!OutputFormat::Jsonl.prints_summary());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::{
|
|
||||||
collections::HashSet,
|
|
||||||
io::{self, Write},
|
|
||||||
sync::atomic::AtomicBool,
|
|
||||||
thread,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_client::{
|
|
||||||
managed::{
|
|
||||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
|
||||||
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
|
||||||
},
|
|
||||||
ClientEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
args::ClientArgs,
|
|
||||||
output::{EventRenderStats, OutputConfig},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::shared::client::expected_probe_count;
|
|
||||||
use crate::shared::client::{
|
|
||||||
is_shutdown_requested,
|
|
||||||
session::{
|
|
||||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
|
||||||
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
|
||||||
|
|
||||||
const MIB: u64 = 1024 * 1024;
|
|
||||||
|
|
||||||
const GIB: u64 = 1024 * MIB;
|
|
||||||
|
|
||||||
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
|
||||||
|
|
||||||
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
|
||||||
|
|
||||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
|
||||||
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
||||||
|
|
||||||
pub fn run_stream(
|
|
||||||
args: ClientArgs,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
if args.list_columns {
|
|
||||||
print!("{}", OutputConfig::list_columns());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let setup = args
|
|
||||||
.prepare()
|
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
|
||||||
let multi_target = setup.is_multi_target();
|
|
||||||
let output_config = OutputConfig::new(
|
|
||||||
args.format,
|
|
||||||
args.columns.as_deref(),
|
|
||||||
args.header,
|
|
||||||
args.verbose,
|
|
||||||
)
|
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
|
||||||
let continuous = args.is_continuous();
|
|
||||||
let target_count = setup.target_count();
|
|
||||||
|
|
||||||
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
|
||||||
eprintln!("{warning}");
|
|
||||||
}
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
|
||||||
setup.managed_config(),
|
|
||||||
setup.managed_targets(),
|
|
||||||
)?;
|
|
||||||
let handle = owner.handle();
|
|
||||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
|
||||||
let mut stream_output = StreamOutput {
|
|
||||||
config: output_config,
|
|
||||||
header_printed: false,
|
|
||||||
print_final_summary: false,
|
|
||||||
show_running_only_summary_note: false,
|
|
||||||
out: &mut stdout,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut stats = setup
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.map(|target| {
|
|
||||||
(
|
|
||||||
target.label.clone(),
|
|
||||||
StatsCollector::new(stats_config(continuous)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
let mut terminal_targets = HashSet::new();
|
|
||||||
let mut dropped_events = 0_u64;
|
|
||||||
let mut interrupted = false;
|
|
||||||
let mut stop_requested = false;
|
|
||||||
let mut subscription_closed = false;
|
|
||||||
loop {
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
interrupted = true;
|
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
|
||||||
drop(handle.stop());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if request_managed_stop_for_peer_close(
|
|
||||||
continuous,
|
|
||||||
interrupted,
|
|
||||||
handle.status().peer_closed_target_outcomes,
|
|
||||||
&mut stop_requested,
|
|
||||||
) {
|
|
||||||
drop(handle.stop());
|
|
||||||
}
|
|
||||||
let drain_state = drain_events(
|
|
||||||
&mut events,
|
|
||||||
&mut stream_output,
|
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
match drain_state {
|
|
||||||
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
|
||||||
ManagedDrainState::Closed => subscription_closed = true,
|
|
||||||
ManagedDrainState::BudgetExhausted => {}
|
|
||||||
}
|
|
||||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if interrupted {
|
|
||||||
eprintln!("interrupted, closing managed run...");
|
|
||||||
}
|
|
||||||
let outcome = owner.join()?;
|
|
||||||
drain_final_events(
|
|
||||||
&mut events,
|
|
||||||
&mut stream_output,
|
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
|
||||||
eprintln!("{warning}");
|
|
||||||
}
|
|
||||||
if outcome.discarded_target_outcomes != 0 {
|
|
||||||
eprintln!(
|
|
||||||
"irtt-rs: warning: {} final target outcomes were discarded",
|
|
||||||
outcome.discarded_target_outcomes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for target in outcome.recent_target_outcomes.iter() {
|
|
||||||
if terminal_targets.insert(target.target.clone()) {
|
|
||||||
report_target_failure(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
||||||
let terminal_error = match &outcome.end_reason {
|
|
||||||
ManagedEndReason::DriverFailed(failure) => {
|
|
||||||
Some(format!("managed driver failed: {failure}"))
|
|
||||||
}
|
|
||||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
|
||||||
.or_else(|| {
|
|
||||||
(!interrupted
|
|
||||||
&& outcome.successful_target_outcomes == 0
|
|
||||||
&& outcome.failed_target_outcomes > 0)
|
|
||||||
.then(|| {
|
|
||||||
format!(
|
|
||||||
"no managed target completed successfully ({} failed)",
|
|
||||||
outcome.failed_target_outcomes
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
|
|
||||||
stream_output.show_running_only_summary_note =
|
|
||||||
continuous && interrupted && stream_output.print_final_summary;
|
|
||||||
for (label, stats) in &stats {
|
|
||||||
if multi_target
|
|
||||||
&& stream_output.print_final_summary
|
|
||||||
&& stream_output.config.prints_summary()
|
|
||||||
{
|
|
||||||
writeln!(stream_output.out)?;
|
|
||||||
writeln!(stream_output.out, "target: {label}")?;
|
|
||||||
}
|
|
||||||
stream_output.print_summary(stats)?;
|
|
||||||
}
|
|
||||||
stream_output.out.flush()?;
|
|
||||||
if let Some(error) = terminal_error {
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_event<W: Write>(
|
|
||||||
event: ManagedEvent,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<String, StatsCollector>,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
match event {
|
|
||||||
ManagedEvent::Client { target, event } => {
|
|
||||||
let collector = stats
|
|
||||||
.entry(target.id.as_str().to_owned())
|
|
||||||
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
|
||||||
print_events_with_stats(
|
|
||||||
stream_output,
|
|
||||||
std::slice::from_ref(&event),
|
|
||||||
Some(target.id.as_str()),
|
|
||||||
collector,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
ManagedEvent::TargetFinished { outcome } => {
|
|
||||||
terminal_targets.insert(outcome.target.clone());
|
|
||||||
report_target_failure(&outcome);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<String, StatsCollector>,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<ManagedDrainState> {
|
|
||||||
drain_managed_events(events, dropped_events, |event| {
|
|
||||||
process_event(event, stream_output, stats, terminal_targets)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_final_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<String, StatsCollector>,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
loop {
|
|
||||||
match events.try_recv() {
|
|
||||||
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
|
|
||||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
|
||||||
*dropped_events = dropped_events.saturating_add(count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_target_failure(target: &ManagedTargetOutcome) {
|
|
||||||
for message in target_failure_messages(target) {
|
|
||||||
eprintln!("{message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
|
|
||||||
let mut messages = Vec::with_capacity(2);
|
|
||||||
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
|
|
||||||
messages.push(format!(
|
|
||||||
"irtt-rs: target {} failed ({} {}): {}",
|
|
||||||
target.target.id, failure.phase, failure.kind, failure.message
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(failure) = &target.cleanup_failure {
|
|
||||||
messages.push(format!(
|
|
||||||
"irtt-rs: target {} cleanup failed ({} {}): {}",
|
|
||||||
target.target.id, failure.phase, failure.kind, failure.message
|
|
||||||
));
|
|
||||||
}
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
|
||||||
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamOutput<'a, W: Write> {
|
|
||||||
config: OutputConfig,
|
|
||||||
header_printed: bool,
|
|
||||||
print_final_summary: bool,
|
|
||||||
show_running_only_summary_note: bool,
|
|
||||||
out: &'a mut W,
|
|
||||||
}
|
|
||||||
impl<W: Write> StreamOutput<'_, W> {
|
|
||||||
fn print_events(
|
|
||||||
&mut self,
|
|
||||||
events: &[ClientEvent],
|
|
||||||
target: Option<&str>,
|
|
||||||
stats_updates: &[EventRenderStats],
|
|
||||||
) -> io::Result<()> {
|
|
||||||
self.print_header()?;
|
|
||||||
for (event, stats_update) in events.iter().zip(stats_updates) {
|
|
||||||
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
|
|
||||||
writeln!(self.out, "{line}")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn print_header(&mut self) -> io::Result<()> {
|
|
||||||
if self.header_printed {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.header_printed = true;
|
|
||||||
if let Some(header) = self.config.render_header() {
|
|
||||||
writeln!(self.out, "{header}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
|
||||||
if self.print_final_summary && self.config.prints_summary() {
|
|
||||||
write!(
|
|
||||||
self.out,
|
|
||||||
"{}",
|
|
||||||
crate::cmd::client::summary::format_summary_with_options(
|
|
||||||
&stats.snapshot(),
|
|
||||||
crate::cmd::client::summary::SummaryFormatOptions {
|
|
||||||
verbose: self.config.summary_verbose(),
|
|
||||||
show_running_only_note: self.show_running_only_summary_note
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_events_with_stats<W: Write>(
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
events: &[ClientEvent],
|
|
||||||
target: Option<&str>,
|
|
||||||
stats: &mut StatsCollector,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let updates = events
|
|
||||||
.iter()
|
|
||||||
.map(|event| EventRenderStats::from(stats.process(event)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
stream_output.print_events(events, target, &updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stats_config(continuous: bool) -> StatsConfig {
|
|
||||||
if continuous {
|
|
||||||
StatsConfig::continuous()
|
|
||||||
} else {
|
|
||||||
StatsConfig::finite()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
|
|
||||||
if args.is_continuous() || args.duration.is_zero() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
|
||||||
let total_probe_count =
|
|
||||||
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
|
|
||||||
// Ask the stats crate what the configuration this run will actually use
|
|
||||||
// retains; the CLI owns the probe count and the thresholds, not the
|
|
||||||
// retention model.
|
|
||||||
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
|
|
||||||
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let formatted = if estimated_bytes >= GIB {
|
|
||||||
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
|
|
||||||
} else {
|
|
||||||
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
|
|
||||||
};
|
|
||||||
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
|
|
||||||
"this may be unsuitable on memory-constrained systems"
|
|
||||||
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
|
|
||||||
"consider shortening the run, increasing the interval, or using continuous mode"
|
|
||||||
} else {
|
|
||||||
"use continuous mode for bounded-memory long-running tests"
|
|
||||||
};
|
|
||||||
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use irtt_client::managed::{
|
|
||||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
fn long_finite_args() -> ClientArgs {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
ClientArgs::try_parse_from([
|
|
||||||
"irtt-cli",
|
|
||||||
"--duration",
|
|
||||||
"4200s",
|
|
||||||
"--interval",
|
|
||||||
"100ms",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
])
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Smallest target count whose aggregate estimate reaches the warning
|
|
||||||
/// threshold, derived from the stats crate's own estimate rather than from
|
|
||||||
/// any bytes-per-probe assumption held here.
|
|
||||||
fn first_warning_target_count(args: &ClientArgs) -> usize {
|
|
||||||
let probes = expected_probe_count(args.duration, args.interval);
|
|
||||||
(1..=1024)
|
|
||||||
.find(|targets| {
|
|
||||||
let total = probes.saturating_mul(*targets as u64);
|
|
||||||
stats_config(false).estimated_retained_bytes(total)
|
|
||||||
>= FINITE_STATS_MEMORY_WARNING_BYTES
|
|
||||||
})
|
|
||||||
.expect("a long finite run should cross the warning threshold within 1024 targets")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finite_stats_memory_warning_accounts_for_target_count() {
|
|
||||||
let args = long_finite_args();
|
|
||||||
let crossing = first_warning_target_count(&args);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
crossing > 1,
|
|
||||||
"this run should need more than one target to cross the threshold, \
|
|
||||||
otherwise the test proves nothing about target-count accounting"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
finite_stats_memory_warning(&args, crossing - 1).is_none(),
|
|
||||||
"an aggregate estimate below the threshold should not warn"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
finite_stats_memory_warning(&args, crossing).is_some(),
|
|
||||||
"an aggregate estimate at the threshold should warn"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
|
||||||
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lagged_events_are_counted_saturatingly() {
|
|
||||||
let mut dropped = 0_u64;
|
|
||||||
dropped = dropped.saturating_add(7);
|
|
||||||
dropped = dropped.saturating_add(3);
|
|
||||||
assert_eq!(dropped, 10);
|
|
||||||
assert_eq!(u64::MAX.saturating_add(1), u64::MAX);
|
|
||||||
assert!(dropped_event_warning(dropped)
|
|
||||||
.unwrap()
|
|
||||||
.contains("dropped 10 managed run events"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
|
|
||||||
let target = ManagedTargetOutcome {
|
|
||||||
target: TargetInstance {
|
|
||||||
id: TargetId::from("edge"),
|
|
||||||
generation: 1,
|
|
||||||
},
|
|
||||||
server_addr: Arc::from("127.0.0.1:2112"),
|
|
||||||
remote: None,
|
|
||||||
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
|
||||||
phase: ManagedTargetFailurePhase::Receiving,
|
|
||||||
kind: ManagedTargetFailureKind::Protocol,
|
|
||||||
message: "primary failure".into(),
|
|
||||||
}),
|
|
||||||
packets_sent: 0,
|
|
||||||
replies_received: 0,
|
|
||||||
duplicates: 0,
|
|
||||||
late: 0,
|
|
||||||
warning_events: 0,
|
|
||||||
cleanup_failure: Some(ManagedTargetFailure {
|
|
||||||
phase: ManagedTargetFailurePhase::Closing,
|
|
||||||
kind: ManagedTargetFailureKind::Socket,
|
|
||||||
message: "cleanup failure".into(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target_failure_messages(&target),
|
|
||||||
[
|
|
||||||
"irtt-rs: target edge failed (receiving protocol): primary failure",
|
|
||||||
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
use std::fmt::Write as _;
|
|
||||||
|
|
||||||
use irtt_stats::{Snapshot, TimeStats};
|
|
||||||
|
|
||||||
use crate::cmd::format::{
|
|
||||||
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
|
|
||||||
format_optional_ns_i128 as format_ns_i128, format_percent,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn format_summary(summary: &Snapshot) -> String {
|
|
||||||
format_summary_with_options(summary, SummaryFormatOptions::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct SummaryFormatOptions {
|
|
||||||
pub verbose: bool,
|
|
||||||
pub show_running_only_note: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
let packets = summary.packets;
|
|
||||||
let loss = summary.loss;
|
|
||||||
|
|
||||||
writeln!(out).unwrap();
|
|
||||||
writeln!(out, "irtt-rs summary").unwrap();
|
|
||||||
if options.show_running_only_note {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
writeln!(out).unwrap();
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
|
||||||
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
writeln!(out, " {}", "-".repeat(82)).unwrap();
|
|
||||||
|
|
||||||
write_time_row(&mut out, "RTT", &summary.rtt.primary);
|
|
||||||
if options.verbose {
|
|
||||||
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
|
|
||||||
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
|
|
||||||
}
|
|
||||||
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
|
|
||||||
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
|
|
||||||
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
|
|
||||||
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
|
|
||||||
write_time_row(
|
|
||||||
&mut out,
|
|
||||||
"receive delay",
|
|
||||||
&summary.one_way_delay.receive_delay,
|
|
||||||
);
|
|
||||||
write_time_row(
|
|
||||||
&mut out,
|
|
||||||
"server processing",
|
|
||||||
&summary.server_processing.processing,
|
|
||||||
);
|
|
||||||
write_time_row(&mut out, "send call", &summary.send_call);
|
|
||||||
write_time_row(&mut out, "timer error", &summary.timer_error);
|
|
||||||
|
|
||||||
writeln!(out).unwrap();
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"packets: sent={} received={} unique={} lost={} loss={}",
|
|
||||||
packets.packets_sent,
|
|
||||||
packets.packets_received,
|
|
||||||
packets.unique_replies,
|
|
||||||
loss.lost_packets,
|
|
||||||
format_percent(loss.packet_loss_percent)
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if packets.duplicates != 0 || packets.late_packets != 0 {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"replies: duplicates={} ({}) late={} ({})",
|
|
||||||
packets.duplicates,
|
|
||||||
format_percent(loss.duplicate_percent),
|
|
||||||
packets.late_packets,
|
|
||||||
format_percent(loss.late_packets_percent)
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"bytes: sent={} received={}",
|
|
||||||
packets.bytes_sent, packets.bytes_received
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
|
|
||||||
write!(out, "server:").unwrap();
|
|
||||||
if let Some(count) = packets.server_packets_received {
|
|
||||||
write!(out, " received={count}").unwrap();
|
|
||||||
}
|
|
||||||
if let Some(window) = packets.server_received_window {
|
|
||||||
write!(out, " window={window:#x}").unwrap();
|
|
||||||
}
|
|
||||||
writeln!(out).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
|
||||||
if value.count == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
|
||||||
value.count,
|
|
||||||
format_ns_i128(value.min_ns),
|
|
||||||
format_ns_f64(value.mean_ns),
|
|
||||||
format_ns_f64_opt(value.median_ns),
|
|
||||||
format_ns_i128(value.max_ns),
|
|
||||||
format_ns_f64(value.stddev_ns())
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use irtt_client::{
|
|
||||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
|
||||||
};
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
|
||||||
use std::{
|
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
time::{Duration, Instant, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
|
||||||
ClientTimestamp {
|
|
||||||
wall: UNIX_EPOCH + offset,
|
|
||||||
mono: Instant::now() + offset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_remote() -> SocketAddr {
|
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_summary_omits_optional_metric_sections() {
|
|
||||||
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
|
|
||||||
let output = format_summary(&summary);
|
|
||||||
|
|
||||||
assert!(output.contains("Metric"));
|
|
||||||
assert!(output.contains("Min"));
|
|
||||||
assert!(output.contains("Mean"));
|
|
||||||
assert!(output.contains("Median"));
|
|
||||||
assert!(output.contains("Max"));
|
|
||||||
assert!(output.contains("Stddev"));
|
|
||||||
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
|
|
||||||
assert!(!output.contains("RTT 0"));
|
|
||||||
assert!(!output.contains("server processing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn summary_formats_counts_and_available_metrics() {
|
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
|
||||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
|
||||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
|
||||||
|
|
||||||
collector.process(&ClientEvent::EchoSent {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
scheduled_at: sent_at.mono,
|
|
||||||
sent_at,
|
|
||||||
bytes: 64,
|
|
||||||
send_call: Duration::from_micros(10),
|
|
||||||
timer_error: Duration::from_micros(2),
|
|
||||||
});
|
|
||||||
collector.process(&ClientEvent::EchoReply {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1500),
|
|
||||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
|
||||||
},
|
|
||||||
server_timing: Some(ServerTiming {
|
|
||||||
receive_wall_ns: None,
|
|
||||||
receive_mono_ns: None,
|
|
||||||
send_wall_ns: None,
|
|
||||||
send_mono_ns: None,
|
|
||||||
midpoint_wall_ns: None,
|
|
||||||
midpoint_mono_ns: None,
|
|
||||||
processing: Some(Duration::from_micros(300)),
|
|
||||||
}),
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = format_summary(&collector.snapshot());
|
|
||||||
|
|
||||||
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
|
|
||||||
assert!(output.contains("bytes: sent=64 received=64"));
|
|
||||||
assert!(output.contains("RTT"));
|
|
||||||
assert!(output.contains("1.2ms"));
|
|
||||||
assert!(!output.contains("raw RTT"));
|
|
||||||
assert!(!output.contains("adjusted RTT"));
|
|
||||||
assert!(output.contains("server processing"));
|
|
||||||
assert!(output.contains("300.0µs"));
|
|
||||||
assert!(output.contains("send call"));
|
|
||||||
assert!(output.contains("10.0µs"));
|
|
||||||
assert!(output.contains("timer error"));
|
|
||||||
assert!(output.contains("2.0µs"));
|
|
||||||
assert!(!output.contains("medians unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the Median cell of the row with this label.
|
|
||||||
fn median_cell(output: &str, label: &str) -> String {
|
|
||||||
let row = output
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.trim_start().starts_with(label))
|
|
||||||
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
|
|
||||||
// Metric, Count, Min, Mean, Median, ...
|
|
||||||
// Counting from the right keeps multi-word labels out of the way.
|
|
||||||
row.split_whitespace()
|
|
||||||
.rev()
|
|
||||||
.nth(2)
|
|
||||||
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collects probes whose send-call, timer-error, and server-processing
|
|
||||||
/// values each have a median distinct from their min, mean, and max, so
|
|
||||||
/// the rendered cell can only come from a real median calculation.
|
|
||||||
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
|
|
||||||
let mut collector = StatsCollector::new(config);
|
|
||||||
for (seq, (send_call_us, timer_error_us, processing_us)) in
|
|
||||||
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let seq = seq as u32;
|
|
||||||
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
|
|
||||||
let sent_at = test_timestamp(offset);
|
|
||||||
let received_at = test_timestamp(offset + Duration::from_micros(1500));
|
|
||||||
|
|
||||||
collector.process(&ClientEvent::EchoSent {
|
|
||||||
seq,
|
|
||||||
remote: test_remote(),
|
|
||||||
scheduled_at: sent_at.mono,
|
|
||||||
sent_at,
|
|
||||||
bytes: 64,
|
|
||||||
send_call: Duration::from_micros(send_call_us),
|
|
||||||
timer_error: Duration::from_micros(timer_error_us),
|
|
||||||
});
|
|
||||||
collector.process(&ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at,
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1500),
|
|
||||||
adjusted: None,
|
|
||||||
effective: SignedDuration::from_nanos(1_500_000),
|
|
||||||
},
|
|
||||||
server_timing: Some(ServerTiming {
|
|
||||||
receive_wall_ns: None,
|
|
||||||
receive_mono_ns: None,
|
|
||||||
send_wall_ns: None,
|
|
||||||
send_mono_ns: None,
|
|
||||||
midpoint_wall_ns: None,
|
|
||||||
midpoint_mono_ns: None,
|
|
||||||
processing: Some(Duration::from_micros(processing_us)),
|
|
||||||
}),
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
collector.snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
|
|
||||||
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
|
|
||||||
|
|
||||||
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
|
|
||||||
// equals the row's min, mean, or max.
|
|
||||||
assert_eq!(median_cell(&output, "send call"), "20.0µs");
|
|
||||||
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
|
|
||||||
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn continuous_summary_still_shows_no_medians_for_those_rows() {
|
|
||||||
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
|
|
||||||
|
|
||||||
for label in ["send call", "timer error", "server processing"] {
|
|
||||||
assert_eq!(
|
|
||||||
median_cell(&output, label),
|
|
||||||
"-",
|
|
||||||
"continuous mode retains no exact samples for {label}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
|
|
||||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
|
||||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
|
||||||
collector.process(&ClientEvent::EchoReply {
|
|
||||||
seq: 1,
|
|
||||||
remote: test_remote(),
|
|
||||||
sent_at: test_timestamp(Duration::from_secs(1)),
|
|
||||||
received_at,
|
|
||||||
rtt: RttSample {
|
|
||||||
raw: Duration::from_micros(1500),
|
|
||||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
|
||||||
effective: SignedDuration::from_nanos(1_200_000),
|
|
||||||
},
|
|
||||||
server_timing: None,
|
|
||||||
one_way: None,
|
|
||||||
received_stats: None,
|
|
||||||
bytes: 64,
|
|
||||||
packet_meta: PacketMeta::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = format_summary_with_options(
|
|
||||||
&collector.snapshot(),
|
|
||||||
SummaryFormatOptions {
|
|
||||||
verbose: true,
|
|
||||||
..SummaryFormatOptions::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(output.contains("raw RTT"));
|
|
||||||
assert!(output.contains("adjusted RTT"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn running_only_note_is_format_option() {
|
|
||||||
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
|
|
||||||
let output = format_summary_with_options(
|
|
||||||
&summary,
|
|
||||||
SummaryFormatOptions {
|
|
||||||
show_running_only_note: true,
|
|
||||||
..SummaryFormatOptions::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(output.contains("irtt-rs summary"));
|
|
||||||
assert!(output.contains("medians unavailable"));
|
|
||||||
assert!(output.contains("continuous mode"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signed_stats_can_format_negative_values() {
|
|
||||||
let stats = TimeStats {
|
|
||||||
count: 1,
|
|
||||||
total_ns: -500,
|
|
||||||
min_ns: Some(-500),
|
|
||||||
max_ns: Some(-500),
|
|
||||||
mean_ns: -500.0,
|
|
||||||
median_ns: Some(-500.0),
|
|
||||||
variance_ns2: 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out = String::new();
|
|
||||||
write_time_row(&mut out, "RTT", &stats);
|
|
||||||
assert!(out.contains("-500ns"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
use std::{
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_server::ServerSet;
|
|
||||||
|
|
||||||
use super::args::ServerArgs;
|
|
||||||
|
|
||||||
/// How often the shutdown future samples the process-wide signal flag.
|
|
||||||
///
|
|
||||||
/// The flag a signal handler sets has no async wake mechanism, so the future
|
|
||||||
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
|
|
||||||
/// after that future becomes ready, including fanning it out to the listeners.
|
|
||||||
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// Runs the configured listeners until the process is asked to shut down.
|
|
||||||
///
|
|
||||||
/// The applet owns exactly one current-thread Tokio runtime and one
|
|
||||||
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
|
|
||||||
/// is an ordinary set of one and takes the same path as a set of several: one
|
|
||||||
/// construction, one shutdown future, one supervision rule. Listener
|
|
||||||
/// concurrency is async I/O concurrency and needs no worker threads.
|
|
||||||
pub fn run_server(
|
|
||||||
args: ServerArgs,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()?;
|
|
||||||
runtime.block_on(serve(args, shutdown_requested))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(
|
|
||||||
args: ServerArgs,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let config = args.server_config();
|
|
||||||
let set = ServerSet::bind(args.bind, config).await?;
|
|
||||||
// Only once every listener is up. Announcing one address and then failing
|
|
||||||
// to bind the next would report a service that never started.
|
|
||||||
for addr in set.local_addrs() {
|
|
||||||
eprintln!("irtt-server: listening on {addr}");
|
|
||||||
}
|
|
||||||
set.run(shutdown_signal(shutdown_requested)).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
|
||||||
while !shutdown_requested.load(Ordering::Relaxed) {
|
|
||||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
|
||||||
/// on the same orchestration path as every other.
|
|
||||||
#[test]
|
|
||||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
|
||||||
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
|
|
||||||
let shutdown_requested = AtomicBool::new(true);
|
|
||||||
|
|
||||||
run_server(args, &shutdown_requested).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
|
||||||
let args = ServerArgs::try_parse_from([
|
|
||||||
"irtt-server",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:0",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:0",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
let shutdown_requested = AtomicBool::new(true);
|
|
||||||
|
|
||||||
run_server(args, &shutdown_requested).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::shared::client::TimestampArg;
|
|
||||||
use crate::shared::client::{
|
|
||||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
|
||||||
ManagedRunSetup, TargetArg, TargetSelection,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
|
||||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
|
||||||
pub struct TuiArgs {
|
|
||||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
|
||||||
#[arg(
|
|
||||||
value_name = "TARGET",
|
|
||||||
num_args = 1..,
|
|
||||||
required = true,
|
|
||||||
value_parser = parse_target,
|
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
|
|
||||||
)]
|
|
||||||
pub targets: Vec<TargetArg>,
|
|
||||||
|
|
||||||
/// Managed group pacing for multi-target mode.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_enum,
|
|
||||||
default_value_t = GroupPacingArg::Staggered,
|
|
||||||
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
|
||||||
)]
|
|
||||||
pub pacing: GroupPacingArg,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
default_value = "0",
|
|
||||||
value_parser = parse_test_duration,
|
|
||||||
help = "Test duration; use 0 for continuous mode",
|
|
||||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
|
||||||
)]
|
|
||||||
pub duration: Duration,
|
|
||||||
|
|
||||||
#[command(flatten)]
|
|
||||||
pub common: CommonClientArgs,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiArgs {
|
|
||||||
/// Validate the selected targets and prepare the managed run.
|
|
||||||
///
|
|
||||||
/// The TUI always requires a target: the parser rejects an empty target
|
|
||||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
|
||||||
prepare_managed_run(
|
|
||||||
&self.common,
|
|
||||||
self.duration,
|
|
||||||
TargetSelection {
|
|
||||||
targets: &self.targets,
|
|
||||||
pacing: self.pacing,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
|
||||||
self.duration == Duration::ZERO
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
|
||||||
self.common.tstamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for TuiArgs {
|
|
||||||
type Target = CommonClientArgs;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.common
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use clap::{CommandFactory, Parser};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
struct SharedOnlyArgs {
|
|
||||||
#[command(flatten)]
|
|
||||||
common: CommonClientArgs,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["irtt-tui"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
TuiArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
|
|
||||||
let mut argv = vec!["shared-only"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
SharedOnlyArgs::try_parse_from(argv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
|
||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
|
||||||
assert_eq!(args.targets.len(), 1);
|
|
||||||
assert_eq!(args.targets[0].label, None);
|
|
||||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
|
||||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
|
||||||
assert!(args.is_continuous());
|
|
||||||
assert_eq!(args.prepare().unwrap().client.duration, None);
|
|
||||||
|
|
||||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
|
||||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
|
||||||
assert_eq!(
|
|
||||||
finite.prepare().unwrap().client.duration,
|
|
||||||
Some(Duration::from_secs(30))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
|
||||||
let help = TuiArgs::command().render_help().to_string();
|
|
||||||
assert!(!help.contains("--output"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multiple_positional_targets_parse() {
|
|
||||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
|
||||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
|
||||||
assert_eq!(specs[1].label, "host-b:2112");
|
|
||||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_labeled_targets_parse() {
|
|
||||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "ams");
|
|
||||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
|
||||||
assert_eq!(specs[1].label, "sg");
|
|
||||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn at_least_one_target_is_required() {
|
|
||||||
// The TUI has no target-free mode, so this is rejected at parse time
|
|
||||||
// rather than during preparation.
|
|
||||||
assert!(parse(&[]).is_err());
|
|
||||||
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_labels_are_rejected() {
|
|
||||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
|
||||||
let err = args.prepare().unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("duplicate target label"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
|
||||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
|
||||||
let specs = args.prepare().unwrap().targets;
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
|
||||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_target_endpoints_are_allowed() {
|
|
||||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
|
||||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_labelled_target_syntax_is_rejected() {
|
|
||||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
|
||||||
assert!(parse(&["label="]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn old_target_option_is_rejected() {
|
|
||||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pacing_option_accepts_supported_values() {
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
|
||||||
.unwrap()
|
|
||||||
.pacing,
|
|
||||||
GroupPacingArg::Staggered
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
|
||||||
.unwrap()
|
|
||||||
.pacing,
|
|
||||||
GroupPacingArg::Burst
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shared_client_options_match_tui_config_mapping() {
|
|
||||||
let shared = [
|
|
||||||
"--interval",
|
|
||||||
"250ms",
|
|
||||||
"--length",
|
|
||||||
"128",
|
|
||||||
"--hmac",
|
|
||||||
"secret",
|
|
||||||
"--clock",
|
|
||||||
"monotonic",
|
|
||||||
"--tstamp",
|
|
||||||
"receive",
|
|
||||||
"--stats",
|
|
||||||
"count",
|
|
||||||
"--sfill",
|
|
||||||
"abc",
|
|
||||||
"--dscp",
|
|
||||||
"46",
|
|
||||||
"--ttl",
|
|
||||||
"64",
|
|
||||||
"--loose",
|
|
||||||
];
|
|
||||||
let tui_args = [
|
|
||||||
"--duration",
|
|
||||||
"30s",
|
|
||||||
"--interval",
|
|
||||||
"250ms",
|
|
||||||
"--length",
|
|
||||||
"128",
|
|
||||||
"--hmac",
|
|
||||||
"secret",
|
|
||||||
"--clock",
|
|
||||||
"monotonic",
|
|
||||||
"--tstamp",
|
|
||||||
"receive",
|
|
||||||
"--stats",
|
|
||||||
"count",
|
|
||||||
"--sfill",
|
|
||||||
"abc",
|
|
||||||
"--dscp",
|
|
||||||
"46",
|
|
||||||
"--ttl",
|
|
||||||
"64",
|
|
||||||
"--loose",
|
|
||||||
"127.0.0.1:2112",
|
|
||||||
];
|
|
||||||
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
|
|
||||||
let common = parse_shared(&shared).unwrap().common;
|
|
||||||
let shared = common.to_client_config(Duration::from_secs(30));
|
|
||||||
|
|
||||||
assert_eq!(shared.duration, tui.duration);
|
|
||||||
assert_eq!(shared.interval, tui.interval);
|
|
||||||
assert_eq!(shared.length, tui.length);
|
|
||||||
assert_eq!(shared.received_stats, tui.received_stats);
|
|
||||||
assert_eq!(shared.stamp_at, tui.stamp_at);
|
|
||||||
assert_eq!(shared.clock, tui.clock);
|
|
||||||
assert_eq!(shared.dscp, tui.dscp);
|
|
||||||
assert_eq!(shared.hmac_key, tui.hmac_key);
|
|
||||||
assert_eq!(shared.server_fill, tui.server_fill);
|
|
||||||
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
|
|
||||||
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tui_help_lists_multi_target_options() {
|
|
||||||
let help = TuiArgs::command().render_help().to_string();
|
|
||||||
assert!(!help.contains("--target "));
|
|
||||||
assert!(help.contains("--pacing <PACING>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use std::{
|
|
||||||
env,
|
|
||||||
ffi::OsString,
|
|
||||||
process::ExitCode,
|
|
||||||
sync::{atomic::AtomicBool, Arc},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
|
|
||||||
signal::install_signal_handler,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn main() -> ExitCode {
|
|
||||||
match run_from_env() {
|
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("irtt-rs: {err}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let argv: Vec<OsString> = env::args_os().collect();
|
|
||||||
let (requested, argv) = match dispatch_from_argv(argv)? {
|
|
||||||
AppletDispatch::Run { applet, argv } => (applet, argv),
|
|
||||||
AppletDispatch::Help(help) => {
|
|
||||||
print!("{help}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
|
||||||
install_signal_handler(Arc::clone(&shutdown_requested))
|
|
||||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
|
||||||
|
|
||||||
match requested {
|
|
||||||
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
|
|
||||||
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
|
|
||||||
RequestedApplet::Server => run_server_applet(argv, shutdown_requested.as_ref()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
fn run_client_applet(
|
|
||||||
argv: Vec<OsString>,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
|
||||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "client"))]
|
|
||||||
fn run_client_applet(
|
|
||||||
_argv: Vec<OsString>,
|
|
||||||
_shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
Err("client applet is not available; rebuild with the client feature".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
fn run_server_applet(
|
|
||||||
argv: Vec<OsString>,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
|
||||||
crate::cmd::server::run_server(args, shutdown_requested)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
fn run_server_applet(
|
|
||||||
_argv: Vec<OsString>,
|
|
||||||
_shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
Err("server applet is not available; rebuild with the server feature".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
fn run_tui_applet(
|
|
||||||
argv: Vec<OsString>,
|
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
|
||||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "tui"))]
|
|
||||||
fn run_tui_applet(
|
|
||||||
_argv: Vec<OsString>,
|
|
||||||
_shutdown_requested: &AtomicBool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
|
|
||||||
|
|
||||||
use super::args::CommonClientArgs;
|
|
||||||
|
|
||||||
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
|
|
||||||
|
|
||||||
impl CommonClientArgs {
|
|
||||||
/// Build the shared client configuration template for a managed run.
|
|
||||||
///
|
|
||||||
/// The template carries no server address: the managed driver replaces it
|
|
||||||
/// per target from that target's configuration, so deriving one here from
|
|
||||||
/// an arbitrary target would be dead data.
|
|
||||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
|
||||||
ClientConfig {
|
|
||||||
duration: (!duration.is_zero()).then_some(duration),
|
|
||||||
interval: self.interval,
|
|
||||||
length: self.length,
|
|
||||||
received_stats: self.stats.into(),
|
|
||||||
stamp_at: self.tstamp.into(),
|
|
||||||
clock: self.clock.into(),
|
|
||||||
dscp: self.dscp,
|
|
||||||
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
|
|
||||||
server_fill: self.server_fill.clone(),
|
|
||||||
negotiation_policy: if self.loose {
|
|
||||||
NegotiationPolicy::Loose
|
|
||||||
} else {
|
|
||||||
NegotiationPolicy::Strict
|
|
||||||
},
|
|
||||||
socket_config: SocketConfig {
|
|
||||||
ttl: self.ttl,
|
|
||||||
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
|
|
||||||
..SocketConfig::default()
|
|
||||||
},
|
|
||||||
..ClientConfig::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
|
|
||||||
let interval_nanos = interval.as_nanos();
|
|
||||||
if interval_nanos == 0 {
|
|
||||||
return u64::MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected = duration
|
|
||||||
.as_nanos()
|
|
||||||
.saturating_add(interval_nanos.saturating_sub(1))
|
|
||||||
/ interval_nanos;
|
|
||||||
expected.min(u128::from(u64::MAX)) as u64
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
pub mod args;
|
|
||||||
pub mod config;
|
|
||||||
pub mod prepare;
|
|
||||||
pub mod session;
|
|
||||||
pub mod targets;
|
|
||||||
|
|
||||||
pub use args::{
|
|
||||||
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
|
||||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
|
||||||
};
|
|
||||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
|
||||||
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
|
|
||||||
pub use session::is_shutdown_requested;
|
|
||||||
pub use targets::{
|
|
||||||
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
|
||||||
TargetSpec,
|
|
||||||
};
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
//! One preparation step from parsed arguments to a runnable managed setup.
|
|
||||||
//!
|
|
||||||
//! Both client applets accept the same target and pacing arguments and then
|
|
||||||
//! have to turn them into the same three things: a validated target set, one
|
|
||||||
//! shared [`ClientConfig`] template, and a [`ManagedPacing`]. Doing that
|
|
||||||
//! separately in each applet is how the CLI ended up deriving its client
|
|
||||||
//! configuration from an arbitrary "primary" target whose address the managed
|
|
||||||
//! driver immediately replaced per target.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use irtt_client::{
|
|
||||||
managed::{ManagedClientConfig, ManagedCompletionPolicy, ManagedPacing},
|
|
||||||
ClientConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
args::CommonClientArgs,
|
|
||||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
|
||||||
PreparedTarget,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Capacity of the lossy managed presentation event channel.
|
|
||||||
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
|
||||||
|
|
||||||
/// Target and pacing arguments common to the client applets.
|
|
||||||
///
|
|
||||||
/// Duration is deliberately absent: the applets disagree about what a duration
|
|
||||||
/// means and what its default is, so each keeps its own.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct TargetSelection<'a> {
|
|
||||||
pub targets: &'a [TargetArg],
|
|
||||||
pub pacing: GroupPacingArg,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything a managed run needs, prepared and validated together.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ManagedRunSetup {
|
|
||||||
/// Validated targets, in the order the user gave them.
|
|
||||||
pub targets: Vec<PreparedTarget>,
|
|
||||||
/// Shared client configuration template.
|
|
||||||
///
|
|
||||||
/// The managed driver supplies each target's address and authentication
|
|
||||||
/// from its [`ManagedTargetConfig`](irtt_client::managed::ManagedTargetConfig),
|
|
||||||
/// so this template carries no target of its own.
|
|
||||||
pub client: ClientConfig,
|
|
||||||
/// Send coordination across active targets.
|
|
||||||
pub pacing: ManagedPacing,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManagedRunSetup {
|
|
||||||
/// Number of validated targets.
|
|
||||||
pub fn target_count(&self) -> usize {
|
|
||||||
self.targets.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this run drives more than one target.
|
|
||||||
pub fn is_multi_target(&self) -> bool {
|
|
||||||
self.targets.len() > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Managed target configurations, in argument order.
|
|
||||||
pub fn managed_targets(&self) -> Vec<irtt_client::managed::ManagedTargetConfig> {
|
|
||||||
self.targets
|
|
||||||
.iter()
|
|
||||||
.map(|target| target.managed.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Managed driver configuration for this run.
|
|
||||||
pub fn managed_config(&self) -> ManagedClientConfig {
|
|
||||||
let target_count = self.target_count();
|
|
||||||
ManagedClientConfig {
|
|
||||||
client: self.client.clone(),
|
|
||||||
pacing: self.pacing,
|
|
||||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
|
||||||
event_capacity: MANAGED_EVENT_CAPACITY,
|
|
||||||
outcome_history_limit: target_count,
|
|
||||||
max_live_target_generations: target_count,
|
|
||||||
..ManagedClientConfig::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the selected targets and build the shared run setup.
|
|
||||||
///
|
|
||||||
/// Targets are validated before any configuration is built, so a bad target set
|
|
||||||
/// fails before the caller can act on a half-prepared run.
|
|
||||||
pub fn prepare_managed_run(
|
|
||||||
common: &CommonClientArgs,
|
|
||||||
duration: Duration,
|
|
||||||
selection: TargetSelection<'_>,
|
|
||||||
) -> Result<ManagedRunSetup, String> {
|
|
||||||
let specs = target_specs(selection.targets)?;
|
|
||||||
let targets = prepare_managed_targets(specs)?;
|
|
||||||
Ok(ManagedRunSetup {
|
|
||||||
targets,
|
|
||||||
client: common.to_client_config(duration),
|
|
||||||
pacing: selection.pacing.into(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
struct CommonOnlyArgs {
|
|
||||||
#[command(flatten)]
|
|
||||||
common: CommonClientArgs,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn common(args: &[&str]) -> CommonClientArgs {
|
|
||||||
let mut argv = vec!["common-only"];
|
|
||||||
argv.extend_from_slice(args);
|
|
||||||
CommonOnlyArgs::try_parse_from(argv).unwrap().common
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unlabeled(addr: &str) -> TargetArg {
|
|
||||||
TargetArg {
|
|
||||||
label: None,
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
|
||||||
TargetArg {
|
|
||||||
label: Some(label.to_owned()),
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare(
|
|
||||||
targets: &[TargetArg],
|
|
||||||
pacing: GroupPacingArg,
|
|
||||||
duration: Duration,
|
|
||||||
) -> Result<ManagedRunSetup, String> {
|
|
||||||
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_single_target_prepares_one_managed_target_and_a_finite_duration() {
|
|
||||||
let setup = prepare(
|
|
||||||
&[unlabeled("127.0.0.1:2112")],
|
|
||||||
GroupPacingArg::Staggered,
|
|
||||||
Duration::from_secs(30),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(setup.target_count(), 1);
|
|
||||||
assert!(!setup.is_multi_target());
|
|
||||||
assert_eq!(setup.targets[0].label, "127.0.0.1:2112");
|
|
||||||
assert_eq!(setup.targets[0].managed.server_addr, "127.0.0.1:2112");
|
|
||||||
assert_eq!(setup.client.duration, Some(Duration::from_secs(30)));
|
|
||||||
assert_eq!(setup.pacing, ManagedPacing::Staggered);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_zero_duration_prepares_a_continuous_client_config() {
|
|
||||||
let setup = prepare(
|
|
||||||
&[unlabeled("127.0.0.1:2112")],
|
|
||||||
GroupPacingArg::Staggered,
|
|
||||||
Duration::ZERO,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(setup.client.duration, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multiple_targets_keep_argument_order_and_share_one_client_config() {
|
|
||||||
let setup = prepare(
|
|
||||||
&[
|
|
||||||
unlabeled("host-a:2112"),
|
|
||||||
unlabeled("host-b:2112"),
|
|
||||||
labeled("sg", "sg.example.test:2112"),
|
|
||||||
],
|
|
||||||
GroupPacingArg::Burst,
|
|
||||||
Duration::from_secs(10),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(setup.is_multi_target());
|
|
||||||
let labels: Vec<_> = setup
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.map(|target| target.label.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(labels, ["host-a:2112", "host-b:2112", "sg"]);
|
|
||||||
assert_eq!(setup.targets[2].managed.server_addr, "sg.example.test:2112");
|
|
||||||
assert_eq!(setup.pacing, ManagedPacing::Burst);
|
|
||||||
|
|
||||||
let config = setup.managed_config();
|
|
||||||
assert_eq!(config.outcome_history_limit, 3);
|
|
||||||
assert_eq!(config.max_live_target_generations, 3);
|
|
||||||
assert_eq!(config.event_capacity, MANAGED_EVENT_CAPACITY);
|
|
||||||
assert_eq!(config.pacing, ManagedPacing::Burst);
|
|
||||||
assert_eq!(config.client, setup.client);
|
|
||||||
assert_eq!(setup.managed_targets().len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_shared_client_config_carries_no_target_of_its_own() {
|
|
||||||
// The managed driver replaces the address, and the CLI's key, per
|
|
||||||
// target, so the template must not pin either to one target.
|
|
||||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")];
|
|
||||||
let setup = prepare_managed_run(
|
|
||||||
&common(&["--hmac", "secret"]),
|
|
||||||
Duration::from_secs(10),
|
|
||||||
TargetSelection {
|
|
||||||
targets: &targets,
|
|
||||||
pacing: GroupPacingArg::Staggered,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
setup.client.server_addr,
|
|
||||||
ClientConfig::default().server_addr
|
|
||||||
);
|
|
||||||
assert_eq!(setup.client.hmac_key, Some(b"secret".to_vec()));
|
|
||||||
// Per-target authentication is not exposed on the command line, so no
|
|
||||||
// target carries an override of the shared key.
|
|
||||||
assert!(setup
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.all(|target| target.managed.auth.is_none()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_target_set_is_rejected_with_the_list_columns_hint() {
|
|
||||||
let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("at least one target is required"));
|
|
||||||
assert!(err.contains("--list-columns"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_duplicate_label_is_rejected_before_any_configuration_is_built() {
|
|
||||||
let targets = vec![
|
|
||||||
unlabeled("host-a:2112"),
|
|
||||||
labeled("host-a:2112", "host-b:2112"),
|
|
||||||
];
|
|
||||||
let err =
|
|
||||||
prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("duplicate target label"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use clap::ValueEnum;
|
|
||||||
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TargetArg {
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub addr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TargetSpec {
|
|
||||||
pub label: String,
|
|
||||||
pub addr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PreparedTarget {
|
|
||||||
pub label: String,
|
|
||||||
pub managed: ManagedTargetConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
||||||
pub enum GroupPacingArg {
|
|
||||||
Staggered,
|
|
||||||
Burst,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<GroupPacingArg> for ManagedPacing {
|
|
||||||
fn from(value: GroupPacingArg) -> Self {
|
|
||||||
match value {
|
|
||||||
GroupPacingArg::Staggered => Self::Staggered,
|
|
||||||
GroupPacingArg::Burst => Self::Burst,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
|
|
||||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
|
||||||
match input.split_once('=') {
|
|
||||||
None => Ok(TargetArg {
|
|
||||||
label: None,
|
|
||||||
addr: input.to_owned(),
|
|
||||||
}),
|
|
||||||
Some((label, addr)) => {
|
|
||||||
if label.is_empty() {
|
|
||||||
return Err("target label must not be empty".to_owned());
|
|
||||||
}
|
|
||||||
if addr.is_empty() {
|
|
||||||
return Err("target address must not be empty".to_owned());
|
|
||||||
}
|
|
||||||
Ok(TargetArg {
|
|
||||||
label: Some(label.to_owned()),
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
|
||||||
let mut specs = Vec::with_capacity(targets.len());
|
|
||||||
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
|
|
||||||
for target in targets {
|
|
||||||
let label = match &target.label {
|
|
||||||
Some(label) => label.clone(),
|
|
||||||
None => {
|
|
||||||
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
|
|
||||||
*count += 1;
|
|
||||||
if *count == 1 {
|
|
||||||
target.addr.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}#{}", target.addr, *count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
specs.push(TargetSpec {
|
|
||||||
label,
|
|
||||||
addr: target.addr.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if specs.is_empty() {
|
|
||||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut labels = HashSet::new();
|
|
||||||
for spec in &specs {
|
|
||||||
if !labels.insert(spec.label.clone()) {
|
|
||||||
return Err(format!("duplicate target label {:?}", spec.label));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(specs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
|
||||||
let mut targets = Vec::with_capacity(specs.len());
|
|
||||||
for spec in specs {
|
|
||||||
targets.push(PreparedTarget {
|
|
||||||
label: spec.label.clone(),
|
|
||||||
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(targets)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn unlabeled(addr: &str) -> TargetArg {
|
|
||||||
TargetArg {
|
|
||||||
label: None,
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
|
||||||
TargetArg {
|
|
||||||
label: Some(label.to_owned()),
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_target_without_equals_is_unlabeled() {
|
|
||||||
let target = parse_target("host.example").unwrap();
|
|
||||||
assert_eq!(target.label, None);
|
|
||||||
assert_eq!(target.addr, "host.example");
|
|
||||||
|
|
||||||
let target = parse_target("host.example:2112").unwrap();
|
|
||||||
assert_eq!(target.label, None);
|
|
||||||
assert_eq!(target.addr, "host.example:2112");
|
|
||||||
|
|
||||||
let target = parse_target("[::1]:2112").unwrap();
|
|
||||||
assert_eq!(target.label, None);
|
|
||||||
assert_eq!(target.addr, "[::1]:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_target_with_equals_splits_on_first() {
|
|
||||||
let target = parse_target("eu=host.example").unwrap();
|
|
||||||
assert_eq!(target.label, Some("eu".to_owned()));
|
|
||||||
assert_eq!(target.addr, "host.example");
|
|
||||||
|
|
||||||
let target = parse_target("eu=host.example:2112").unwrap();
|
|
||||||
assert_eq!(target.label, Some("eu".to_owned()));
|
|
||||||
assert_eq!(target.addr, "host.example:2112");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_target_rejects_empty_label_or_address() {
|
|
||||||
assert!(parse_target("=host.example").is_err());
|
|
||||||
assert!(parse_target("eu=").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
|
||||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
|
|
||||||
let specs = target_specs(&targets).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
|
||||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
|
||||||
|
|
||||||
let targets = vec![
|
|
||||||
unlabeled("host-a:2112"),
|
|
||||||
labeled("host-a:2112", "host-b:2112"),
|
|
||||||
];
|
|
||||||
let err = target_specs(&targets).unwrap_err();
|
|
||||||
assert!(err.contains("duplicate target label"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_specs_preserve_argument_order_with_mixed_labels() {
|
|
||||||
let targets = vec![
|
|
||||||
unlabeled("local"),
|
|
||||||
labeled("eu", "eu.example"),
|
|
||||||
unlabeled("backup"),
|
|
||||||
labeled("us", "us.example"),
|
|
||||||
];
|
|
||||||
let specs = target_specs(&targets).unwrap();
|
|
||||||
|
|
||||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
|
||||||
assert_eq!(labels, ["local", "eu", "backup", "us"]);
|
|
||||||
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
|
|
||||||
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
|
|
||||||
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
|
|
||||||
let specs = target_specs(&targets).unwrap();
|
|
||||||
|
|
||||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
|
||||||
assert_eq!(labels, ["foo", "eu", "foo#2"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_specs_reject_generated_suffix_collisions() {
|
|
||||||
let targets = vec![
|
|
||||||
unlabeled("host.example"),
|
|
||||||
unlabeled("host.example"),
|
|
||||||
labeled("host.example#2", "other.example"),
|
|
||||||
];
|
|
||||||
let err = target_specs(&targets).unwrap_err();
|
|
||||||
assert!(err.contains("duplicate target label"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
|
|
||||||
let specs = vec![
|
|
||||||
TargetSpec {
|
|
||||||
label: "a".to_owned(),
|
|
||||||
addr: "example.test".to_owned(),
|
|
||||||
},
|
|
||||||
TargetSpec {
|
|
||||||
label: "b".to_owned(),
|
|
||||||
addr: "example.test".to_owned(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let targets = prepare_managed_targets(specs).unwrap();
|
|
||||||
assert_eq!(targets[0].managed.server_addr, "example.test");
|
|
||||||
assert_eq!(targets[1].managed.server_addr, "example.test");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
use std::process::{Command, Output};
|
|
||||||
|
|
||||||
fn irtt_rs() -> Command {
|
|
||||||
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn irtt_rs_with_arg0(arg0: &str) -> Command {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
|
|
||||||
let mut command = irtt_rs();
|
|
||||||
command.arg0(arg0);
|
|
||||||
command
|
|
||||||
}
|
|
||||||
|
|
||||||
fn output_text(output: &Output) -> String {
|
|
||||||
let mut text = String::new();
|
|
||||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
|
||||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
|
||||||
text
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_help_shows_dispatcher_help() {
|
|
||||||
let output = irtt_rs().arg("--help").output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
|
|
||||||
assert!(text.contains("client"));
|
|
||||||
assert!(text.contains("tui"));
|
|
||||||
assert!(text.contains("server"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_without_applet_errors() {
|
|
||||||
let output = irtt_rs().output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(text.contains("choose an applet"), "{text}");
|
|
||||||
assert!(text.contains("client"));
|
|
||||||
assert!(text.contains("tui"));
|
|
||||||
assert!(text.contains("server"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn canonical_client_subcommand_dispatches_to_client() {
|
|
||||||
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
|
||||||
assert!(text.contains("--format <FORMAT>"));
|
|
||||||
assert!(text.contains("--columns <COLUMNS>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "client"))]
|
|
||||||
#[test]
|
|
||||||
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
|
|
||||||
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(
|
|
||||||
text.contains("client applet is not available; rebuild with the client feature"),
|
|
||||||
"{text}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn canonical_server_subcommand_dispatches_to_server() {
|
|
||||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
|
||||||
assert!(text.contains("--bind <ADDR>"));
|
|
||||||
assert!(text.contains("--max-sessions <COUNT>"));
|
|
||||||
assert!(text.contains("--idle-timeout <DURATION>"));
|
|
||||||
assert!(text.contains("--timestamp-allowance <MODE>"));
|
|
||||||
assert!(text.contains("--no-dscp"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
#[test]
|
|
||||||
fn canonical_server_subcommand_reports_unavailable_when_disabled() {
|
|
||||||
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(
|
|
||||||
text.contains("server applet is not available; rebuild with the server feature"),
|
|
||||||
"{text}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn standalone_server_binary_exposes_the_same_arguments() {
|
|
||||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
|
||||||
assert!(text.contains("--bind <ADDR>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn client_applet_name_dispatches_to_client() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-cli")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
|
||||||
assert!(text.contains("--format <FORMAT>"));
|
|
||||||
assert!(text.contains("--columns <COLUMNS>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, not(feature = "client")))]
|
|
||||||
#[test]
|
|
||||||
fn client_applet_name_reports_unavailable_when_disabled() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-cli")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(
|
|
||||||
text.contains("client applet is not available; rebuild with the client feature"),
|
|
||||||
"{text}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, feature = "tui"))]
|
|
||||||
#[test]
|
|
||||||
fn tui_applet_name_dispatches_to_tui_when_enabled() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-tui")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible TUI client"));
|
|
||||||
assert!(text.contains("--duration <DURATION>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, not(feature = "tui")))]
|
|
||||||
#[test]
|
|
||||||
fn tui_applet_name_reports_unavailable_when_disabled() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-tui")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(text.contains("TUI applet is not available"), "{text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, feature = "server"))]
|
|
||||||
#[test]
|
|
||||||
fn server_applet_name_dispatches_to_server_when_enabled() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-server")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(output.status.success(), "{text}");
|
|
||||||
assert!(text.contains("Minimal IRTT-compatible UDP server"));
|
|
||||||
assert!(text.contains("--bind <ADDR>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, not(feature = "server")))]
|
|
||||||
#[test]
|
|
||||||
fn server_applet_name_reports_unavailable_when_disabled() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-server")
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(text.contains("server applet is not available"), "{text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn unknown_irtt_applet_name_errors() {
|
|
||||||
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
|
|
||||||
let text = output_text(&output);
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
|
||||||
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
|
|
||||||
assert!(text.contains("irtt-cli"));
|
|
||||||
assert!(text.contains("irtt-tui"));
|
|
||||||
assert!(text.contains("irtt-server"));
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
@@ -26,30 +62,30 @@ configuration (`try_enable_tx_timestamping` restores it explicitly) and
|
|||||||
timing falls back to userspace `sent_at` as it always has. TX capability
|
timing falls back to userspace `sent_at` as it always has. TX capability
|
||||||
failure is never a public error and is never logged from the library.
|
failure is never a public error and is never logged from the library.
|
||||||
|
|
||||||
### Correlation invariant: kernel ID == wire sequence
|
### Best-effort kernel-ID correlation
|
||||||
|
|
||||||
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
|
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
|
||||||
sequence number — no separate correlation map. This is safe because:
|
correlation ID — no separate correlation map. On the normal successful-send
|
||||||
|
path, both counters start at zero after Open and advance together. This is
|
||||||
|
not a permanent identity: a kernel ID can theoretically be consumed while a
|
||||||
|
send is being prepared and then survive a subsequent send failure, leaving
|
||||||
|
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
|
||||||
|
assumption that failed or nonblocking sends cannot cause that desynchrony.
|
||||||
|
|
||||||
- **A failed/nonblocking send never consumes an ID.** Linux increments the
|
The effect is confined to the optional timing enhancement. A kernel timestamp
|
||||||
per-socket ID counter (`sk->sk_tskey`) speculatively while building a
|
whose ID cannot be matched to a pending or retained timed-out probe is
|
||||||
datagram and rolls the increment back (`atomic_dec`) on every error path
|
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
|
||||||
out of `__ip_append_data`/`__ip6_append_data`
|
userspace fallback. **After every failed probe submission, disable kernel-ID
|
||||||
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
|
correlation for the rest of that session.** A gap means an otherwise plausible
|
||||||
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
|
later ID can name the wrong probe, so subsequent error-queue timestamps must
|
||||||
gap in, or otherwise advance, the ID the kernel will assign to the next
|
be discarded rather than attached by ID. This is safe because:
|
||||||
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
|
|
||||||
performs the same increment for the non-corked path.) Client-side, the
|
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
|
||||||
existing send transaction only calls `commit_probe_sent` — which is the
|
successful Open (`SessionMachine::commit_open` builds a fresh
|
||||||
only thing that advances `next_wire_seq` — after a successful socket send;
|
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
|
||||||
a `WouldBlock` result never reaches it.
|
`commit_probe_sent`, after a confirmed successful socket send. TX
|
||||||
- **The two counters start together and advance together.** `wire_seq`
|
timestamping is enabled after that same successful Open and before any probe
|
||||||
starts at 0 on every successful Open (`SessionMachine::commit_open` builds
|
is sent, so the normal zero points line up.
|
||||||
a fresh `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1
|
|
||||||
only via `commit_probe_sent`, which only runs after a confirmed successful
|
|
||||||
send — the same condition under which the kernel's counter advances by 1.
|
|
||||||
TX timestamping is enabled only after that same successful Open and before
|
|
||||||
any probe is sent, so both counters' zero points line up exactly.
|
|
||||||
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
|
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
|
||||||
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
|
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
|
||||||
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
|
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
|
||||||
@@ -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
|
||||||
@@ -93,6 +131,29 @@ risks clearing readiness state that the normal Echo receive path depends on.
|
|||||||
There is no background task or second `AsyncFd` around the Tokio-owned
|
There is no background task or second `AsyncFd` around the Tokio-owned
|
||||||
socket.
|
socket.
|
||||||
|
|
||||||
|
### Testing kernel timestamp behavior
|
||||||
|
|
||||||
|
The requirements above describe production behavior, not a required unit-test
|
||||||
|
architecture.
|
||||||
|
|
||||||
|
Prefer Linux tests that exercise the real socket and kernel timestamp path
|
||||||
|
through production APIs. A test intended to prove TX timestamp support must
|
||||||
|
actually require and observe a kernel TX timestamp when running on a supported
|
||||||
|
Linux environment; accepting the userspace fallback does not prove that path.
|
||||||
|
|
||||||
|
Likewise, prefer observable socket/client behavior for genuine error-queue
|
||||||
|
errors and bounded draining where it can reasonably be induced.
|
||||||
|
|
||||||
|
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
|
||||||
|
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
|
||||||
|
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
|
||||||
|
decoding and ABI safety; `irtt-client` tests should primarily prove its own
|
||||||
|
observable interpretation and behavior through the real path.
|
||||||
|
|
||||||
|
If some invariant cannot reasonably be induced through a production interface,
|
||||||
|
a small focused internal test may be retained, but the burden is on that test to
|
||||||
|
justify why integration coverage is insufficient.
|
||||||
|
|
||||||
## Retained state
|
## Retained state
|
||||||
|
|
||||||
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
||||||
@@ -127,7 +188,8 @@ remains anchored to before the send, on purpose, so the existing pre-send
|
|||||||
timeout/overflow/no-send contract is unchanged. Only the *measurement*
|
timeout/overflow/no-send contract is unchanged. Only the *measurement*
|
||||||
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
|
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
|
||||||
samples a `sent_at`, never calls `finalize_probe_commit`'s matching
|
samples a `sent_at`, never calls `finalize_probe_commit`'s matching
|
||||||
`commit_probe_sent`, and never advances the machine or schedule; each retry
|
`commit_probe_sent`, and never advances the machine; the managed runner advances cadence only
|
||||||
|
after an accepted send; each retry
|
||||||
gets its own fresh pre-send anchor, and only a successful attempt's anchor
|
gets its own fresh pre-send anchor, and only a successful attempt's anchor
|
||||||
and `sent_at` are ever retained.
|
and `sent_at` are ever retained.
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.0"
|
version = "0.5.3"
|
||||||
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
|
homepage.workspace = true
|
||||||
readme.workspace = true
|
readme = "README.md"
|
||||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||||
categories = ["network-programming"]
|
categories = ["network-programming"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
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.20"
|
thiserror = "2.0.21"
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
tokio = { version = "1.53.2", 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 }
|
||||||
@@ -25,9 +25,23 @@ default = []
|
|||||||
ancillary = ["dep:nix"]
|
ancillary = ["dep:nix"]
|
||||||
tokio = ["dep:tokio"]
|
tokio = ["dep:tokio"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
irtt-server.workspace = true
|
irtt-server.workspace = true
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "blocking_client"
|
||||||
|
doc-scrape-examples = true
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "tokio_client"
|
||||||
|
required-features = ["tokio"]
|
||||||
|
doc-scrape-examples = true
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "managed_client"
|
||||||
|
required-features = ["tokio"]
|
||||||
|
doc-scrape-examples = true
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# irtt-client
|
||||||
|
|
||||||
|
Reusable client/session library for IRTT-compatible round-trip-time probing:
|
||||||
|
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
|
||||||
|
classification, and a typed event stream.
|
||||||
|
|
||||||
|
## Looking for the CLI?
|
||||||
|
|
||||||
|
If you just want to run probes from the command line, use the
|
||||||
|
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
|
||||||
|
which installs the `irtt-client` binary. This crate is the library the
|
||||||
|
binaries are built on.
|
||||||
|
|
||||||
|
## API tiers
|
||||||
|
|
||||||
|
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
|
||||||
|
default build.
|
||||||
|
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
|
||||||
|
own a runtime and drive readiness directly.
|
||||||
|
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
|
||||||
|
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
|
||||||
|
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
|
||||||
|
own dedicated current-thread runtime.
|
||||||
|
|
||||||
|
`Client` and `AsyncClient` send one probe whenever called. They expose the
|
||||||
|
negotiated parameters, pending-probe status, and timeout deadlines; callers
|
||||||
|
own cadence and run duration. The managed driver provides fixed cadence,
|
||||||
|
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
|
||||||
|
sends return `Result<SendReceipt, SendProbeError>` instead of event lists.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Tokio stays optional: the default build has no runtime and no Tokio
|
||||||
|
dependency at all.
|
||||||
|
|
||||||
|
See `examples/` in the repository for runnable examples of each tier.
|
||||||
|
|
||||||
|
## Reusable configuration
|
||||||
|
|
||||||
|
`ClientConfig` contains no endpoint. The low-level APIs take it separately:
|
||||||
|
`Client::connect(endpoint, config)` and
|
||||||
|
`AsyncClient::connect(endpoint, config).await`, accepting `String` or `&str`
|
||||||
|
endpoints. An omitted port defaults to 2112.
|
||||||
|
|
||||||
|
Configuration is grouped by ownership:
|
||||||
|
|
||||||
|
- `address_family`: `AddressFamily::{Any, Ipv4, Ipv6}`, defaulting to `Any`.
|
||||||
|
It filters remote resolution; `Ipv6` also sets `IPV6_V6ONLY`.
|
||||||
|
- `socket`: `SocketConfig` for local bind, device/routing options, and TTL.
|
||||||
|
- `request`: `SessionRequest` for duration, interval, length, received stats,
|
||||||
|
timestamps, clock, DSCP, server fill, and run mode.
|
||||||
|
- `open`: `OpenPolicy` for attempt `timeouts` and `negotiation` policy.
|
||||||
|
- `auth`, `probe_timeout`, and `max_pending_probes`: authentication and local
|
||||||
|
probe tracking policy.
|
||||||
|
|
||||||
|
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||||
|
own ID, endpoint, and authentication inheritance/override; shared
|
||||||
|
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||||
|
|
||||||
|
Blocking receive policy belongs to `Client`:
|
||||||
|
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||||
|
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||||
|
Open attempts use only `open.timeouts`, restoring the adapter setting afterward.
|
||||||
|
Async and managed clients have no blocking receive-timeout setting.
|
||||||
|
|
||||||
|
This breaks the former flat config, endpoint-in-config, socket family flags,
|
||||||
|
and `SocketConfig.recv_timeout` APIs. Use the grouped fields and adapter method
|
||||||
|
directly; defaults and protocol/runtime behavior are preserved.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
`ClientConfig.auth` is concrete: `Authentication::Unauthenticated` or
|
||||||
|
`Authentication::Hmac(HmacKey::new(bytes))`. Keys share their bytes across
|
||||||
|
clones and print only `HmacKey([REDACTED])` through `Debug`. Borrowing key
|
||||||
|
material requires an explicit `as_bytes()` call. An empty key still selects
|
||||||
|
HMAC authentication; textual key syntax belongs to the application.
|
||||||
|
|
||||||
|
For managed targets, `ManagedTargetConfig.auth` defaults to `TargetAuth::Inherit`.
|
||||||
|
`TargetAuth::Override(Authentication::Unauthenticated)` disables shared
|
||||||
|
authentication, and `TargetAuth::Override(Authentication::Hmac(key))` replaces
|
||||||
|
the shared key. Initial construction and update planning resolve inheritance
|
||||||
|
into concrete session authentication before execution. Switching between
|
||||||
|
inheritance and an override replaces the target generation even when their
|
||||||
|
resolved authentication is equal.
|
||||||
|
|
||||||
|
This replaces the former `ClientConfig.hmac_key` and `ClientAuthConfig` API;
|
||||||
|
there are no compatibility aliases.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||||
|
implementation of an IRTT-compatible protocol stack.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Runtime-free blocking client example.
|
||||||
|
//!
|
||||||
|
//! Opens a session against a local IRTT-compatible server, sends a few
|
||||||
|
//! probes, and prints the events observed. Uses only [`Client`], which has no
|
||||||
|
//! Tokio dependency and works in a default (`--no-default-features`) build.
|
||||||
|
//!
|
||||||
|
//! Run a server first, then run this example:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||||
|
//! cargo run -p irtt-client --example blocking_client
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Without a reachable server this exits quickly with an open-timeout error
|
||||||
|
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||||
|
//! fast demonstration; a real caller would normally keep the default retry
|
||||||
|
//! schedule.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest};
|
||||||
|
|
||||||
|
const RUN_DURATION: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let config = ClientConfig {
|
||||||
|
open: OpenPolicy {
|
||||||
|
// A single short attempt keeps this example fast when no server is
|
||||||
|
// reachable; production callers should generally keep the default.
|
||||||
|
timeouts: vec![Duration::from_millis(300)],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
request: SessionRequest {
|
||||||
|
duration: Some(RUN_DURATION),
|
||||||
|
interval: Duration::from_millis(200),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut client = match Client::connect("127.0.0.1:2112", config) {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("failed to prepare client socket: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = client.set_recv_timeout(Some(Duration::from_millis(100))) {
|
||||||
|
eprintln!("failed to configure receive timeout: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let negotiated = match client.open() {
|
||||||
|
Ok(OpenOutcome::Started(started)) => {
|
||||||
|
println!("session opened: {started:?}");
|
||||||
|
started.negotiation
|
||||||
|
}
|
||||||
|
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||||
|
println!("{completed:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This loop owns cadence and duration. The transport sends whenever called.
|
||||||
|
let interval = negotiated.accepted.interval;
|
||||||
|
let start = Instant::now();
|
||||||
|
let end = start
|
||||||
|
+ negotiated
|
||||||
|
.accepted
|
||||||
|
.duration
|
||||||
|
.expect("example requests a finite run");
|
||||||
|
let deadline = end + client.probe_timeout();
|
||||||
|
let mut next_send = start;
|
||||||
|
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= end && !client.has_pending_probes() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if now < end && now >= next_send {
|
||||||
|
match client.send_probe() {
|
||||||
|
Ok(receipt) => {
|
||||||
|
print_event(&receipt.into());
|
||||||
|
while next_send <= Instant::now() {
|
||||||
|
next_send += interval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||||
|
print_event(&(*receipt).into());
|
||||||
|
}
|
||||||
|
eprintln!("send failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match client.recv_once() {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("receive failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match client.poll_timeouts() {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("timeout polling failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.is_peer_closed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match client.close() {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => eprintln!("close failed: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_event(event: &ClientEvent) {
|
||||||
|
match event {
|
||||||
|
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||||
|
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||||
|
}
|
||||||
|
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||||
|
other => println!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Managed multi-target client example (`tokio` feature).
|
||||||
|
//!
|
||||||
|
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
|
||||||
|
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
|
||||||
|
//! that does not want to touch Tokio directly can still get managed
|
||||||
|
//! multi-target orchestration, retries, and a lossy event stream.
|
||||||
|
//!
|
||||||
|
//! Run a server first, then run this example:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||||
|
//! cargo run -p irtt-client --example managed_client --features tokio
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This example does not wait for the managed run to finish naturally: it
|
||||||
|
//! requests a stop after a fixed short window so it exits promptly even
|
||||||
|
//! without a reachable server.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use irtt_client::managed::{
|
||||||
|
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
|
||||||
|
ManagedTargetConfig,
|
||||||
|
};
|
||||||
|
use irtt_client::{ClientConfig, OpenPolicy, SessionRequest};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let config = ManagedClientConfig {
|
||||||
|
client: ClientConfig {
|
||||||
|
open: OpenPolicy {
|
||||||
|
timeouts: vec![Duration::from_millis(300)],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
request: SessionRequest {
|
||||||
|
duration: None,
|
||||||
|
interval: Duration::from_millis(200),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
|
||||||
|
|
||||||
|
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
|
||||||
|
{
|
||||||
|
Ok(started) => started,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("failed to start managed client: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stop_after = Duration::from_secs(1);
|
||||||
|
let deadline = std::time::Instant::now() + stop_after;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(event) => print_event(&event),
|
||||||
|
Err(_) => std::thread::sleep(Duration::from_millis(50)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `stop()` requests the stop synchronously; the returned receipt is only
|
||||||
|
// useful for awaiting durable acknowledgement, which this blocking
|
||||||
|
// example does not need.
|
||||||
|
std::mem::drop(owner.handle().stop());
|
||||||
|
match owner.join() {
|
||||||
|
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
|
||||||
|
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_event(event: &ManagedEvent) {
|
||||||
|
match event {
|
||||||
|
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
|
||||||
|
other => println!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Low-level Tokio client example (`tokio` feature).
|
||||||
|
//!
|
||||||
|
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
|
||||||
|
//! adapter to reach for when you already have a Tokio runtime and want to
|
||||||
|
//! await sends/receives yourself, rather than use the higher-level `managed`
|
||||||
|
//! driver (see the `managed_client` example).
|
||||||
|
//!
|
||||||
|
//! Run a server first, then run this example:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||||
|
//! cargo run -p irtt-client --example tokio_client --features tokio
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Without a reachable server this exits quickly with an open-timeout error
|
||||||
|
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||||
|
//! fast demonstration.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use irtt_client::{
|
||||||
|
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Upper bound on how long the receive wait can go without also calling
|
||||||
|
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
|
||||||
|
/// probe's own timeout deadline, so this example polls on a short fixed
|
||||||
|
/// cadence instead of waiting for the full `probe_timeout()` from whenever
|
||||||
|
/// this loop last ran — that would let poll_timeouts() classify a lost
|
||||||
|
/// probe (and its dependent statistics/session state) far later than it
|
||||||
|
/// actually became lost.
|
||||||
|
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_io()
|
||||||
|
.enable_time()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build Tokio runtime");
|
||||||
|
|
||||||
|
runtime.block_on(run());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() {
|
||||||
|
let config = ClientConfig {
|
||||||
|
open: OpenPolicy {
|
||||||
|
// A single short attempt keeps this example fast when no server is
|
||||||
|
// reachable; production callers should generally keep the default.
|
||||||
|
timeouts: vec![Duration::from_millis(300)],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
request: SessionRequest {
|
||||||
|
duration: Some(Duration::from_secs(2)),
|
||||||
|
interval: Duration::from_millis(200),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut client = match AsyncClient::connect("127.0.0.1:2112", config).await {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("failed to prepare client socket: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let negotiated = match client.open().await {
|
||||||
|
Ok(OpenOutcome::Started(started)) => {
|
||||||
|
println!("session opened: {started:?}");
|
||||||
|
started.negotiation
|
||||||
|
}
|
||||||
|
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||||
|
println!("{completed:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This loop owns cadence and duration. The transport sends whenever called.
|
||||||
|
let interval = negotiated.accepted.interval;
|
||||||
|
let start = Instant::now();
|
||||||
|
let end = start
|
||||||
|
+ negotiated
|
||||||
|
.accepted
|
||||||
|
.duration
|
||||||
|
.expect("example requests a finite run");
|
||||||
|
let deadline = end + client.probe_timeout();
|
||||||
|
let mut next_send = start;
|
||||||
|
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||||
|
if Instant::now() >= end && !client.has_pending_probes() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Bound the receive wait by the next send/timeout deadline, rather
|
||||||
|
// than awaiting recv() unconditionally: a lost or rate-limited reply
|
||||||
|
// must not stall pacing or timeout classification indefinitely.
|
||||||
|
let wake_at = (next_send < end)
|
||||||
|
.then_some(next_send)
|
||||||
|
.into_iter()
|
||||||
|
.chain(client.next_probe_timeout_deadline())
|
||||||
|
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
|
||||||
|
.min()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let recv_result = tokio::select! {
|
||||||
|
events = client.recv() => Some(events),
|
||||||
|
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
|
||||||
|
};
|
||||||
|
if let Some(result) = recv_result {
|
||||||
|
match result {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("receive failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() < end && Instant::now() >= next_send {
|
||||||
|
match client.send_probe().await {
|
||||||
|
Ok(receipt) => {
|
||||||
|
print_event(&receipt.into());
|
||||||
|
while next_send <= Instant::now() {
|
||||||
|
next_send += interval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||||
|
print_event(&(*receipt).into());
|
||||||
|
}
|
||||||
|
eprintln!("send failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match client.poll_timeouts() {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("timeout polling failed: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.is_peer_closed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match client.close().await {
|
||||||
|
Ok(events) => events.iter().for_each(print_event),
|
||||||
|
Err(err) => eprintln!("close failed: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_event(event: &ClientEvent) {
|
||||||
|
match event {
|
||||||
|
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||||
|
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||||
|
}
|
||||||
|
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||||
|
other => println!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,10 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use std::{
|
|
||||||
cell::{Cell, RefCell},
|
|
||||||
collections::VecDeque,
|
|
||||||
};
|
|
||||||
|
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::{
|
client::validate_datagram_length,
|
||||||
echo_sent_event,
|
|
||||||
schedule::{instant_abs_diff, ProbeSchedule},
|
|
||||||
validate_datagram_length,
|
|
||||||
},
|
|
||||||
config::ClientConfig,
|
config::ClientConfig,
|
||||||
error::ClientError,
|
error::ClientError,
|
||||||
event::{ClientEvent, OpenOutcome},
|
event::{ClientEvent, OpenOutcome},
|
||||||
@@ -29,9 +19,10 @@ use crate::{
|
|||||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
|
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
|
||||||
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
|
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
|
||||||
},
|
},
|
||||||
socket::{connect_tokio_udp_socket, resolve_remote_tokio, validate_open_timeouts},
|
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.
|
||||||
@@ -54,13 +45,9 @@ const OPEN_POLL_WORK_BUDGET: usize = 64;
|
|||||||
/// this crate already bounds open-attempt work keeps that impossible.
|
/// this crate already bounds open-attempt work keeps that impossible.
|
||||||
const RECV_POLL_WORK_BUDGET: usize = 64;
|
const RECV_POLL_WORK_BUDGET: usize = 64;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::client::ProbeSendTimestamps;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PreparedAsyncOpen {
|
struct PreparedAsyncOpen {
|
||||||
machine: PreparedOpenAcceptance,
|
machine: PreparedOpenAcceptance,
|
||||||
schedule: Option<ProbeSchedule>,
|
|
||||||
recv_buffer_len: Option<usize>,
|
recv_buffer_len: Option<usize>,
|
||||||
negotiated_traffic_class: Option<u8>,
|
negotiated_traffic_class: Option<u8>,
|
||||||
}
|
}
|
||||||
@@ -99,7 +86,7 @@ impl AsyncOpenCleanup {
|
|||||||
/// Owned state for one asynchronous open transaction.
|
/// Owned state for one asynchronous open transaction.
|
||||||
///
|
///
|
||||||
/// This state deliberately contains no reference to [`AsyncClient`], allowing
|
/// This state deliberately contains no reference to [`AsyncClient`], allowing
|
||||||
/// a future managed target to store the client and opening state side by side.
|
/// a managed target to store the client and opening state side by side.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct AsyncOpenState {
|
pub(crate) struct AsyncOpenState {
|
||||||
attempt: usize,
|
attempt: usize,
|
||||||
@@ -156,11 +143,6 @@ impl AsyncOpenState {
|
|||||||
self.stopped_after_current_attempt
|
self.stopped_after_current_attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn has_retained_cleanup(&self) -> bool {
|
|
||||||
self.cleanup.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -173,80 +155,40 @@ impl AsyncOpenState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum InjectedSend {
|
|
||||||
WouldBlock,
|
|
||||||
Error,
|
|
||||||
ReportedLength(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct AsyncClientTestHooks {
|
|
||||||
sends: RefCell<VecDeque<InjectedSend>>,
|
|
||||||
send_attempts: Cell<usize>,
|
|
||||||
receive_would_block: Cell<usize>,
|
|
||||||
/// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
|
|
||||||
/// that should report `io::ErrorKind::Interrupted` instead of performing
|
|
||||||
/// a real nonblocking receive, deterministically exercising interrupted-
|
|
||||||
/// syscall retry behavior.
|
|
||||||
inject_recv_interrupted: Cell<usize>,
|
|
||||||
fail_open_dscp: Cell<bool>,
|
|
||||||
fail_cleanup_send: Cell<bool>,
|
|
||||||
fail_peer_close_dscp: Cell<bool>,
|
|
||||||
fail_dscp_restore: Cell<bool>,
|
|
||||||
pause_open_cleanup_before_send: Cell<bool>,
|
|
||||||
pause_open_before_writable: Cell<bool>,
|
|
||||||
pause_open_before_readable: Cell<bool>,
|
|
||||||
pause_probe_before_writable: Cell<bool>,
|
|
||||||
pause_close_before_writable: Cell<bool>,
|
|
||||||
pause_close_after_would_block: Cell<bool>,
|
|
||||||
probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
|
|
||||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl AsyncClientTestHooks {
|
|
||||||
fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
|
|
||||||
self.send_attempts.set(self.send_attempts.get() + 1);
|
|
||||||
if let Some(injected) = self.sends.borrow_mut().pop_front() {
|
|
||||||
return match injected {
|
|
||||||
InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
|
|
||||||
InjectedSend::Error => Err(io::Error::other("injected send failure")),
|
|
||||||
InjectedSend::ReportedLength(bytes) => Ok(bytes),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
socket.try_send(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
|
|
||||||
self.probe_timestamps.borrow_mut().pop_front()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consumes one pending injected interruption, if any, reporting whether
|
|
||||||
/// the caller should report `io::ErrorKind::Interrupted` instead of
|
|
||||||
/// attempting a real receive.
|
|
||||||
fn take_injected_interrupted(&self) -> bool {
|
|
||||||
let remaining = self.inject_recv_interrupted.get();
|
|
||||||
if remaining == 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.inject_recv_interrupted.set(remaining - 1);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Low-level Tokio IRTT client for one connected UDP target.
|
/// Low-level Tokio IRTT client for one connected UDP target.
|
||||||
///
|
///
|
||||||
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
||||||
/// methods are polled by the caller and require a current Tokio runtime with I/O
|
/// methods are polled by the caller and require a current Tokio runtime with I/O
|
||||||
/// and time enabled.
|
/// and time enabled.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// Use this low-level adapter when the application already owns Tokio and wants
|
||||||
|
/// to drive the session lifecycle itself. This example type-checks without
|
||||||
|
/// contacting a server.
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use irtt_client::{AsyncClient, ClientConfig};
|
||||||
|
///
|
||||||
|
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let mut client = AsyncClient::connect("127.0.0.1:2112", ClientConfig::default()).await?;
|
||||||
|
/// let outcome = client.open().await?;
|
||||||
|
/// println!("opened: {outcome:?}");
|
||||||
|
/// let sent = client.send_probe().await?;
|
||||||
|
/// println!("sent: {sent:?}");
|
||||||
|
/// let received = client.recv().await?;
|
||||||
|
/// println!("received: {received:?}");
|
||||||
|
/// let timed_out = client.poll_timeouts()?;
|
||||||
|
/// println!("timeouts: {timed_out:?}");
|
||||||
|
/// let closed = client.close().await?;
|
||||||
|
/// println!("closed: {closed:?}");
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AsyncClient {
|
pub struct AsyncClient {
|
||||||
socket: tokio::net::UdpSocket,
|
socket: tokio::net::UdpSocket,
|
||||||
machine: SessionMachine,
|
machine: SessionMachine,
|
||||||
schedule: Option<ProbeSchedule>,
|
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
recv_buffer: Vec<u8>,
|
recv_buffer: Vec<u8>,
|
||||||
applied_traffic_class: Option<u8>,
|
applied_traffic_class: Option<u8>,
|
||||||
@@ -255,37 +197,39 @@ pub struct AsyncClient {
|
|||||||
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
|
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
|
||||||
/// invariant this tracks; it is the same adapter-owned capability flag.
|
/// invariant this tracks; it is the same adapter-owned capability flag.
|
||||||
tx_timestamping_enabled: bool,
|
tx_timestamping_enabled: bool,
|
||||||
#[cfg(test)]
|
|
||||||
test_hooks: AsyncClientTestHooks,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncClient {
|
impl AsyncClient {
|
||||||
/// Resolve the configured server and construct one connected Tokio UDP
|
/// Resolve the endpoint and construct one connected Tokio UDP
|
||||||
/// socket.
|
/// socket.
|
||||||
///
|
///
|
||||||
/// Polling this future without a current Tokio runtime returns
|
/// Polling this future without a current Tokio runtime returns
|
||||||
/// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
|
/// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
|
||||||
/// drivers is outside this type's runtime contract.
|
/// drivers is outside this type's runtime contract.
|
||||||
pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
|
///
|
||||||
|
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
|
||||||
|
/// IPv6 literals may be bracketed or unbracketed with the default port.
|
||||||
|
/// The reusable config contains no endpoint identity.
|
||||||
|
pub async fn connect(
|
||||||
|
endpoint: impl AsRef<str>,
|
||||||
|
config: ClientConfig,
|
||||||
|
) -> Result<Self, ClientError> {
|
||||||
tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
|
tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
|
||||||
validate_open_timeouts(&config.open_timeouts)?;
|
config.open.validate()?;
|
||||||
let remote = resolve_remote_tokio(&config).await?;
|
let remote = resolve_remote_tokio(endpoint.as_ref(), config.address_family).await?;
|
||||||
let machine = SessionMachine::new(config.clone(), remote)?;
|
let machine = SessionMachine::new(config.clone(), remote)?;
|
||||||
let prepared_open = machine.prepare_open_request()?;
|
let prepared_open = machine.prepare_open_request()?;
|
||||||
let socket = connect_tokio_udp_socket(&config.socket_config, remote)?;
|
let socket = connect_tokio_udp_socket(&config.socket, remote, config.address_family)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
socket,
|
socket,
|
||||||
machine,
|
machine,
|
||||||
schedule: None,
|
|
||||||
remote,
|
remote,
|
||||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||||
applied_traffic_class: None,
|
applied_traffic_class: None,
|
||||||
prepared_open: Some(prepared_open),
|
prepared_open: Some(prepared_open),
|
||||||
prepared_probe: None,
|
prepared_probe: None,
|
||||||
tx_timestamping_enabled: false,
|
tx_timestamping_enabled: false,
|
||||||
#[cfg(test)]
|
|
||||||
test_hooks: AsyncClientTestHooks::default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,21 +241,26 @@ impl AsyncClient {
|
|||||||
/// ignored without resending the request.
|
/// ignored without resending the request.
|
||||||
///
|
///
|
||||||
/// Dropping this future before trusted acceptance leaves the local machine
|
/// Dropping this future before trusted acceptance leaves the local machine
|
||||||
/// connected with no committed schedule or negotiated adapter state. If it
|
/// connected with no negotiated adapter state. If it is dropped during
|
||||||
/// is dropped during best-effort post-token cleanup, cleanup may remain
|
/// best-effort post-token cleanup, cleanup may remain incomplete because
|
||||||
/// incomplete because this low-level client never detaches cleanup work.
|
/// this low-level client never detaches cleanup work.
|
||||||
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||||
let mut state = AsyncOpenState::new();
|
let mut state = AsyncOpenState::new();
|
||||||
poll_fn(|cx| self.poll_open(&mut state, cx)).await
|
poll_fn(|cx| self.poll_open(&mut state, cx)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one caller-paced probe with transactional protocol and schedule
|
/// Send one echo probe now, committing protocol state only after acceptance.
|
||||||
/// commits.
|
/// The caller owns pacing and run duration.
|
||||||
///
|
///
|
||||||
/// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +320,7 @@ impl AsyncClient {
|
|||||||
return self.poll_open_cleanup(state, cx);
|
return self.poll_open_cleanup(state, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let attempt_count = self.machine.config().open_timeouts.len();
|
let attempt_count = self.machine.config().open.timeouts.len();
|
||||||
if state.attempt >= attempt_count {
|
if state.attempt >= attempt_count {
|
||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||||
}
|
}
|
||||||
@@ -380,17 +329,13 @@ impl AsyncClient {
|
|||||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
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) {
|
||||||
return Poll::Ready(Err(error));
|
return Poll::Ready(Err(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.request_submitted {
|
if !state.request_submitted {
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.pause_open_before_writable.replace(false) {
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||||
Poll::Pending => return Poll::Pending,
|
Poll::Pending => return Poll::Pending,
|
||||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||||
@@ -405,9 +350,6 @@ impl AsyncClient {
|
|||||||
.prepared_open
|
.prepared_open
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("connected clients retain their prepared open request");
|
.expect("connected clients retain their prepared open request");
|
||||||
#[cfg(test)]
|
|
||||||
let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_result = self.socket.try_send(&request.bytes);
|
let send_result = self.socket.try_send(&request.bytes);
|
||||||
match send_result {
|
match send_result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
@@ -421,10 +363,6 @@ impl AsyncClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.pause_open_before_readable.replace(false) {
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
|
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
|
||||||
Poll::Pending => return Poll::Pending,
|
Poll::Pending => return Poll::Pending,
|
||||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||||
@@ -438,10 +376,6 @@ impl AsyncClient {
|
|||||||
Poll::Ready(Ok(true)) => {}
|
Poll::Ready(Ok(true)) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.take_injected_interrupted() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
|
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
|
||||||
Ok(datagram) => datagram,
|
Ok(datagram) => datagram,
|
||||||
// WouldBlock is a readiness false positive; Interrupted
|
// WouldBlock is a readiness false positive; Interrupted
|
||||||
@@ -489,7 +423,7 @@ impl AsyncClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let prepared = match self.prepare_async_open(machine, datagram.received_at) {
|
let prepared = match self.prepare_async_open(machine) {
|
||||||
Ok(prepared) => prepared,
|
Ok(prepared) => prepared,
|
||||||
Err(failure) => {
|
Err(failure) => {
|
||||||
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
|
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
|
||||||
@@ -508,147 +442,99 @@ 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 = prepared,
|
Ok(prepared) => self.prepared_probe = Some(prepared),
|
||||||
Err(error) => return Poll::Ready(Err(error)),
|
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.prepared_probe.is_none() {
|
|
||||||
return Poll::Ready(Ok(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.pause_probe_before_writable.replace(false) {
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
match self.socket.poll_send_ready(cx) {
|
match self.socket.poll_send_ready(cx) {
|
||||||
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,
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
let test_timestamps = self.test_hooks.take_probe_timestamps();
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let permission_at = Instant::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let permission_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.permission_at)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
|
|
||||||
let schedule = self
|
|
||||||
.schedule
|
|
||||||
.as_mut()
|
|
||||||
.expect("open sessions always have a probe schedule");
|
|
||||||
if !schedule.permit_probe_at(permission_at) {
|
|
||||||
self.prepared_probe = None;
|
|
||||||
return Poll::Ready(Ok(Vec::new()));
|
|
||||||
}
|
|
||||||
let prepared = self
|
let prepared = self
|
||||||
.prepared_probe
|
.prepared_probe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.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 schedule_commit =
|
|
||||||
match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
|
|
||||||
Ok(commit) => commit,
|
|
||||||
Err(error) => return Poll::Ready(Err(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();
|
||||||
let scheduled_at = schedule_commit.scheduled_at;
|
|
||||||
// Private pre-send anchor: finalizes all fallible commit work
|
// Private pre-send anchor: finalizes all fallible commit work
|
||||||
// (timeout deadline arithmetic, kernel TX lower bound) before
|
// (timeout deadline arithmetic, kernel TX lower bound) before
|
||||||
// the send attempt. A WouldBlock retry samples a fresh one on
|
// the send attempt. A WouldBlock retry samples a fresh one on
|
||||||
// the next loop iteration; only the successful attempt's anchor
|
// the next loop iteration; only the successful attempt's anchor
|
||||||
// is retained (via `machine_commit`/`commit_probe_sent` below).
|
// is retained (via `machine_commit`/`commit_probe_sent` below).
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_anchor = ClientTimestamp::now();
|
let send_anchor = ClientTimestamp::now();
|
||||||
#[cfg(test)]
|
|
||||||
let send_anchor = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_anchor)
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let machine_commit = match self
|
let machine_commit = match self
|
||||||
.machine
|
.machine
|
||||||
.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))),
|
||||||
};
|
};
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_call_start = Instant::now();
|
let send_call_start = Instant::now();
|
||||||
#[cfg(test)]
|
|
||||||
let send_call_start = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_call_start)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_result = self.socket.try_send(&prepared.bytes);
|
let send_result = self.socket.try_send(&prepared.bytes);
|
||||||
let bytes = match send_result {
|
let bytes = match send_result {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
// No sent_at measurement, no machine commit, no schedule
|
// No measurement or machine commit: retry the transaction
|
||||||
// commit: the transaction is fully discarded and retried
|
|
||||||
// with a fresh send_anchor on the next loop iteration.
|
// with a fresh send_anchor on the next loop iteration.
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
self.machine.invalidate_kernel_tx_correlation();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.machine.invalidate_kernel_tx_correlation();
|
||||||
|
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||||
|
error,
|
||||||
|
))));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_finished_at = Instant::now();
|
let send_finished_at = Instant::now();
|
||||||
#[cfg(test)]
|
|
||||||
let send_finished_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_finished_at)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
// Public post-send measurement, captured only after the
|
// Public post-send measurement, captured only after the
|
||||||
// successful send above. The subsequent machine/schedule commit
|
// successful send above. The subsequent machine commit
|
||||||
// is infallible.
|
// is infallible.
|
||||||
#[cfg(not(test))]
|
|
||||||
let sent_at = ClientTimestamp::now();
|
let sent_at = ClientTimestamp::now();
|
||||||
#[cfg(test)]
|
|
||||||
let sent_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.sent_at)
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let sent = self
|
let sent = self
|
||||||
.machine
|
.machine
|
||||||
.commit_probe_sent(machine_commit, sent_at, bytes);
|
.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||||
schedule.commit(schedule_commit);
|
|
||||||
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);
|
||||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
|
||||||
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
|
let receipt = SendReceipt {
|
||||||
return Poll::Ready(Err(error));
|
seq: sent.seq,
|
||||||
}
|
remote: self.remote,
|
||||||
if let Err(error) = self.drain_tx_timestamps() {
|
sent_at: sent.sent_at,
|
||||||
return Poll::Ready(Err(error));
|
bytes: sent.bytes,
|
||||||
}
|
|
||||||
events.push(echo_sent_event(
|
|
||||||
self.remote,
|
|
||||||
sent,
|
|
||||||
send_call,
|
send_call,
|
||||||
scheduled_at,
|
};
|
||||||
timer_error,
|
if let Err(source) = validate_datagram_length(expected_bytes, bytes) {
|
||||||
));
|
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||||
return Poll::Ready(Ok(events));
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Err(source) = self.drain_tx_timestamps() {
|
||||||
|
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||||
|
receipt,
|
||||||
|
source: Box::new(source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Poll::Ready(Ok(receipt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,17 +554,7 @@ impl AsyncClient {
|
|||||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.receive_would_block.get() > 0 {
|
|
||||||
self.test_hooks
|
|
||||||
.receive_would_block
|
|
||||||
.set(self.test_hooks.receive_would_block.get() - 1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.take_injected_interrupted() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
|
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
|
||||||
Ok(datagram) => datagram,
|
Ok(datagram) => datagram,
|
||||||
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
|
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
|
||||||
@@ -712,9 +588,8 @@ impl AsyncClient {
|
|||||||
Err(error) => return Poll::Ready(Err(error)),
|
Err(error) => return Poll::Ready(Err(error)),
|
||||||
};
|
};
|
||||||
if self.machine.is_peer_closed() {
|
if self.machine.is_peer_closed() {
|
||||||
self.schedule = None;
|
|
||||||
self.prepared_probe = None;
|
self.prepared_probe = None;
|
||||||
if self.clear_peer_close_dscp().is_ok() {
|
if clear_dscp_on_tokio_socket(&self.socket, self.remote).is_ok() {
|
||||||
self.applied_traffic_class = None;
|
self.applied_traffic_class = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,10 +622,6 @@ impl AsyncClient {
|
|||||||
let previous_traffic_class = self.applied_traffic_class;
|
let previous_traffic_class = self.applied_traffic_class;
|
||||||
let expected_bytes = prepared.bytes.len();
|
let expected_bytes = prepared.bytes.len();
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.pause_close_before_writable.replace(false) {
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
match self.socket.poll_send_ready(cx) {
|
match self.socket.poll_send_ready(cx) {
|
||||||
Poll::Pending => return Poll::Pending,
|
Poll::Pending => return Poll::Pending,
|
||||||
Poll::Ready(Ok(())) => {}
|
Poll::Ready(Ok(())) => {}
|
||||||
@@ -758,19 +629,11 @@ impl AsyncClient {
|
|||||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(error) = self.clear_close_dscp() {
|
if let Err(error) = clear_dscp_on_tokio_socket(&self.socket, self.remote) {
|
||||||
return Poll::Ready(Err(error));
|
return Poll::Ready(Err(error));
|
||||||
}
|
}
|
||||||
let mut rollback = DscpRollback::armed(
|
let mut rollback =
|
||||||
&self.socket,
|
DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
|
||||||
self.remote,
|
|
||||||
previous_traffic_class,
|
|
||||||
#[cfg(test)]
|
|
||||||
&self.test_hooks.fail_dscp_restore,
|
|
||||||
);
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_result = self.socket.try_send(prepared.bytes);
|
let send_result = self.socket.try_send(prepared.bytes);
|
||||||
let bytes = match send_result {
|
let bytes = match send_result {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
@@ -778,28 +641,18 @@ impl AsyncClient {
|
|||||||
if let Err(error) = rollback.restore() {
|
if let Err(error) = rollback.restore() {
|
||||||
return Poll::Ready(Err(error));
|
return Poll::Ready(Err(error));
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.pause_close_after_would_block.replace(false) {
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let close_sent_at = ClientTimestamp::now();
|
let close_sent_at = ClientTimestamp::now();
|
||||||
#[cfg(test)]
|
|
||||||
let close_sent_at = self
|
|
||||||
.test_hooks
|
|
||||||
.close_sent_at
|
|
||||||
.take()
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let event = self
|
let event = self
|
||||||
.machine
|
.machine
|
||||||
.commit_local_close(prepared.commit, close_sent_at);
|
.commit_local_close(prepared.commit, close_sent_at);
|
||||||
rollback.disarm();
|
rollback.disarm();
|
||||||
self.schedule = None;
|
|
||||||
self.prepared_probe = None;
|
self.prepared_probe = None;
|
||||||
self.applied_traffic_class = None;
|
self.applied_traffic_class = None;
|
||||||
|
|
||||||
@@ -811,33 +664,21 @@ impl AsyncClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the next scheduled probe deadline.
|
|
||||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
|
||||||
if !self.machine.is_open() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.schedule.as_ref()?.next_send_deadline()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn probe_interval(&self) -> Option<Duration> {
|
|
||||||
if !self.machine.is_open() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.schedule.as_ref().map(ProbeSchedule::interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the configured local probe timeout.
|
/// Return the configured local probe timeout.
|
||||||
pub fn probe_timeout(&self) -> Duration {
|
pub fn probe_timeout(&self) -> Duration {
|
||||||
self.machine.probe_timeout()
|
self.machine.probe_timeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether the current run has completed.
|
/// Accepted semantics and exact peer parameters of the currently live session.
|
||||||
pub fn is_run_complete(&self) -> bool {
|
///
|
||||||
self.machine.is_terminal()
|
/// Returns `None` before Open, after Close, and after no-test completion.
|
||||||
|| self
|
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
|
||||||
.schedule
|
self.machine.negotiation()
|
||||||
.as_ref()
|
}
|
||||||
.is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty())
|
|
||||||
|
/// Whether any sent probes still await a reply or timeout.
|
||||||
|
pub fn has_pending_probes(&self) -> bool {
|
||||||
|
!self.machine.pending_is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether an authenticated peer close ended the session.
|
/// Return whether an authenticated peer close ended the session.
|
||||||
@@ -853,7 +694,8 @@ impl AsyncClient {
|
|||||||
self.machine.packets_sent()
|
self.machine.packets_sent()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||||
|
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||||
self.machine.next_probe_timeout_deadline()
|
self.machine.next_probe_timeout_deadline()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,81 +707,24 @@ impl AsyncClient {
|
|||||||
self.prepared_probe = None;
|
self.prepared_probe = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn retain_open_cleanup_once(&self) {
|
|
||||||
self.test_hooks.fail_open_dscp.set(true);
|
|
||||||
self.test_hooks.pause_open_cleanup_before_send.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
||||||
self.socket.poll_recv_ready(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn remove_pending_for_test(
|
|
||||||
&mut self,
|
|
||||||
wire_seq: u32,
|
|
||||||
) -> Option<crate::probe::PendingProbe> {
|
|
||||||
self.machine.remove_pending_for_test(wire_seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
|
|
||||||
self.machine.replace_pending_for_test(probe);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
|
||||||
let Some(schedule) = self.schedule.as_mut() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some(deadline) = schedule.next_send_deadline() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if deadline > now {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let commit = schedule.preflight_managed_commit(deadline, now)?;
|
|
||||||
schedule.commit(commit);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_async_open(
|
fn prepare_async_open(
|
||||||
&mut self,
|
&mut self,
|
||||||
machine: PreparedOpenAcceptance,
|
machine: PreparedOpenAcceptance,
|
||||||
opened_at: ClientTimestamp,
|
|
||||||
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
|
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
|
||||||
let Some(negotiated) = machine.normal_negotiated() else {
|
let Some(negotiated) = machine.normal_negotiated() else {
|
||||||
return Ok(PreparedAsyncOpen {
|
return Ok(PreparedAsyncOpen {
|
||||||
machine,
|
machine,
|
||||||
schedule: None,
|
|
||||||
recv_buffer_len: None,
|
recv_buffer_len: None,
|
||||||
negotiated_traffic_class: None,
|
negotiated_traffic_class: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
|
||||||
Ok(schedule) => schedule,
|
|
||||||
Err(primary) => {
|
|
||||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
|
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
|
||||||
Ok(size) => size,
|
Ok(size) => size,
|
||||||
Err(primary) => {
|
Err(primary) => {
|
||||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
|
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
|
||||||
Ok(traffic_class) => traffic_class,
|
|
||||||
Err(_) => {
|
|
||||||
return Err(Box::new(PreparedAsyncOpenFailure {
|
|
||||||
primary: ClientError::InvalidConfig {
|
|
||||||
reason: "negotiated dscp must be in range 0..=255".to_owned(),
|
|
||||||
},
|
|
||||||
machine,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
|
let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
|
||||||
if let Err(source) = self.recv_buffer.try_reserve(additional) {
|
if let Err(source) = self.recv_buffer.try_reserve(additional) {
|
||||||
return Err(Box::new(PreparedAsyncOpenFailure {
|
return Err(Box::new(PreparedAsyncOpenFailure {
|
||||||
@@ -950,17 +735,7 @@ impl AsyncClient {
|
|||||||
machine,
|
machine,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
|
|
||||||
Err(ClientError::SocketOption {
|
|
||||||
operation: "set negotiated DSCP",
|
|
||||||
remote: self.remote,
|
|
||||||
source: io::Error::other("injected negotiated DSCP failure"),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
|
|
||||||
};
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let dscp_result = apply_traffic_class_to_tokio_socket(
|
let dscp_result = apply_traffic_class_to_tokio_socket(
|
||||||
&self.socket,
|
&self.socket,
|
||||||
self.remote,
|
self.remote,
|
||||||
@@ -973,7 +748,6 @@ impl AsyncClient {
|
|||||||
|
|
||||||
Ok(PreparedAsyncOpen {
|
Ok(PreparedAsyncOpen {
|
||||||
machine,
|
machine,
|
||||||
schedule: Some(schedule),
|
|
||||||
recv_buffer_len: Some(recv_buffer_len),
|
recv_buffer_len: Some(recv_buffer_len),
|
||||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||||
})
|
})
|
||||||
@@ -984,7 +758,7 @@ impl AsyncClient {
|
|||||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||||
}
|
}
|
||||||
let outcome = self.machine.commit_open(prepared.machine);
|
let outcome = self.machine.commit_open(prepared.machine);
|
||||||
self.schedule = prepared.schedule;
|
|
||||||
self.applied_traffic_class = prepared.negotiated_traffic_class;
|
self.applied_traffic_class = prepared.negotiated_traffic_class;
|
||||||
self.prepared_open = None;
|
self.prepared_open = None;
|
||||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||||
@@ -1017,23 +791,6 @@ impl AsyncClient {
|
|||||||
else {
|
else {
|
||||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
if self
|
|
||||||
.test_hooks
|
|
||||||
.pause_open_cleanup_before_send
|
|
||||||
.replace(false)
|
|
||||||
{
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
|
||||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_result = self.test_hooks.try_send(&self.socket, packet);
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_result = self.socket.try_send(packet);
|
let send_result = self.socket.try_send(packet);
|
||||||
match send_result {
|
match send_result {
|
||||||
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||||
@@ -1055,22 +812,6 @@ impl AsyncClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_peer_close_dscp.replace(false) {
|
|
||||||
return Err(ClientError::SocketOption {
|
|
||||||
operation: "clear DSCP before close",
|
|
||||||
remote: self.remote,
|
|
||||||
source: io::Error::other("injected peer-close DSCP cleanup failure"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
|
||||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||||
let _ = match previous_traffic_class {
|
let _ = match previous_traffic_class {
|
||||||
Some(traffic_class) => {
|
Some(traffic_class) => {
|
||||||
@@ -1086,8 +827,6 @@ struct DscpRollback<'a> {
|
|||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
previous_traffic_class: Option<u8>,
|
previous_traffic_class: Option<u8>,
|
||||||
armed: bool,
|
armed: bool,
|
||||||
#[cfg(test)]
|
|
||||||
fail_restore: &'a Cell<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> DscpRollback<'a> {
|
impl<'a> DscpRollback<'a> {
|
||||||
@@ -1095,15 +834,12 @@ impl<'a> DscpRollback<'a> {
|
|||||||
socket: &'a tokio::net::UdpSocket,
|
socket: &'a tokio::net::UdpSocket,
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
previous_traffic_class: Option<u8>,
|
previous_traffic_class: Option<u8>,
|
||||||
#[cfg(test)] fail_restore: &'a Cell<bool>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
socket,
|
socket,
|
||||||
remote,
|
remote,
|
||||||
previous_traffic_class,
|
previous_traffic_class,
|
||||||
armed: true,
|
armed: true,
|
||||||
#[cfg(test)]
|
|
||||||
fail_restore,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,14 +847,7 @@ impl<'a> DscpRollback<'a> {
|
|||||||
if !self.armed {
|
if !self.armed {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
if self.fail_restore.replace(false) {
|
|
||||||
return Err(ClientError::SocketOption {
|
|
||||||
operation: "restore negotiated DSCP",
|
|
||||||
remote: self.remote,
|
|
||||||
source: io::Error::other("injected DSCP restore failure"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
match self.previous_traffic_class {
|
match self.previous_traffic_class {
|
||||||
Some(traffic_class) => {
|
Some(traffic_class) => {
|
||||||
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
|
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
|
||||||
@@ -1171,6 +900,3 @@ fn poll_readable_until(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
//! A real `irtt-server` running in-process, for tests that need to prove
|
|
||||||
//! `AsyncClient` and the blocking `Client` are equivalent against a real peer.
|
|
||||||
//!
|
|
||||||
//! This duplicates `crates/irtt-integration-tests/tests/support/in_tree_server.rs`
|
|
||||||
//! rather than depending on it: this module backs private (`super::*`) unit
|
|
||||||
//! tests here, while the integration crate covers black-box client/server
|
|
||||||
//! behavior, and neither can depend on the other's test-only code.
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
|||||||
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
|
/// Shared HMAC key bytes. Debug output never includes the bytes or a fingerprint.
|
||||||
|
///
|
||||||
|
/// An empty key is still an explicitly configured HMAC key.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct HmacKey(Arc<[u8]>);
|
||||||
|
|
||||||
|
impl HmacKey {
|
||||||
|
/// Store key bytes without interpreting textual key syntax.
|
||||||
|
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
|
||||||
|
Self(bytes.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly borrow the key material for authentication.
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for HmacKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("HmacKey([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete authentication for a low-level client session.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum Authentication {
|
||||||
|
/// Send and accept packets without HMAC authentication.
|
||||||
|
#[default]
|
||||||
|
Unauthenticated,
|
||||||
|
/// Authenticate packets with this key, including when it is empty.
|
||||||
|
Hmac(HmacKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Authentication {
|
||||||
|
pub(crate) fn hmac_key(&self) -> Option<&[u8]> {
|
||||||
|
match self {
|
||||||
|
Self::Unauthenticated => None,
|
||||||
|
Self::Hmac(key) => Some(key.as_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-463
@@ -4,43 +4,24 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use std::cell::Cell;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ClientConfig, RecvBudget},
|
config::{ClientConfig, RecvBudget},
|
||||||
error::ClientError,
|
error::ClientError,
|
||||||
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, validate_open_timeouts},
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) mod schedule;
|
|
||||||
|
|
||||||
use schedule::{instant_abs_diff, ProbeSchedule};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum ProbeScheduleMode {
|
|
||||||
CallerPaced,
|
|
||||||
#[cfg(test)]
|
|
||||||
Managed {
|
|
||||||
scheduled_at: Instant,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PreparedClientOpen {
|
struct PreparedClientOpen {
|
||||||
machine: PreparedOpenAcceptance,
|
machine: PreparedOpenAcceptance,
|
||||||
schedule: Option<ProbeSchedule>,
|
|
||||||
recv_buffer_len: Option<usize>,
|
recv_buffer_len: Option<usize>,
|
||||||
negotiated_traffic_class: Option<u8>,
|
negotiated_traffic_class: Option<u8>,
|
||||||
previous_traffic_class: Option<u8>,
|
previous_traffic_class: Option<u8>,
|
||||||
@@ -53,63 +34,44 @@ struct PreparedClientOpenFailure {
|
|||||||
machine: PreparedOpenAcceptance,
|
machine: PreparedOpenAcceptance,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ClientTestHooks {
|
|
||||||
fail_open_dscp: Cell<bool>,
|
|
||||||
fail_open_timeout_restore: Cell<bool>,
|
|
||||||
fail_close_event_reserve: Cell<bool>,
|
|
||||||
fail_close_dscp_clear: Cell<bool>,
|
|
||||||
fail_close_send: Cell<bool>,
|
|
||||||
close_reported_len: Cell<Option<usize>>,
|
|
||||||
close_send_attempts: Cell<usize>,
|
|
||||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
|
||||||
fail_cleanup_send: Cell<bool>,
|
|
||||||
recv_buffer_len_override: Cell<Option<usize>>,
|
|
||||||
fail_dscp_restore: Cell<bool>,
|
|
||||||
last_restored_read_timeout: Cell<Option<Duration>>,
|
|
||||||
prepared_active_session_before_dscp: Cell<bool>,
|
|
||||||
/// Number of subsequent receive attempts that should report
|
|
||||||
/// `io::ErrorKind::Interrupted` before a real receive is attempted.
|
|
||||||
/// Decremented on every injected receive; `0` means inject nothing.
|
|
||||||
inject_recv_interrupted: Cell<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub(crate) struct ProbeSendTimestamps {
|
|
||||||
pub(crate) permission_at: Instant,
|
|
||||||
/// Private pre-send anchor: timeout deadline / kernel TX lower bound.
|
|
||||||
pub(crate) send_anchor: ClientTimestamp,
|
|
||||||
pub(crate) send_call_start: Instant,
|
|
||||||
pub(crate) send_finished_at: Instant,
|
|
||||||
/// Public post-send measurement: RTT / OWD fallback / timer_error.
|
|
||||||
pub(crate) sent_at: ClientTimestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::{
|
|
||||||
session::machine::{
|
|
||||||
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
|
|
||||||
unix_epoch_ns_i64, update_highest_received,
|
|
||||||
},
|
|
||||||
session::negotiate_params,
|
|
||||||
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Low-level synchronous IRTT client.
|
/// Low-level synchronous IRTT client.
|
||||||
///
|
///
|
||||||
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
|
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
|
||||||
/// session, send probes, receive replies, poll timeouts, and close. Callers
|
/// session, send probes, receive replies, poll timeouts, and close. Callers
|
||||||
/// that do not need to own this loop can use the unified managed API in
|
/// that do not need to own this loop can use the unified managed API in
|
||||||
/// [`crate::managed`] when the `tokio` feature is enabled.
|
/// [`crate::managed`] when the `tokio` feature is enabled.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
|
||||||
|
/// polling between sends; see the standalone example for a complete loop.
|
||||||
|
/// This example type-checks without contacting a server:
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use irtt_client::{Client, ClientConfig};
|
||||||
|
///
|
||||||
|
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let mut client = Client::connect("127.0.0.1:2112", ClientConfig::default())?;
|
||||||
|
/// let outcome = client.open()?;
|
||||||
|
/// println!("opened: {outcome:?}");
|
||||||
|
/// let sent = client.send_probe()?;
|
||||||
|
/// println!("sent: {sent:?}");
|
||||||
|
/// let received = client.recv_once()?;
|
||||||
|
/// println!("received: {received:?}");
|
||||||
|
/// let timed_out = client.poll_timeouts()?;
|
||||||
|
/// println!("timeouts: {timed_out:?}");
|
||||||
|
/// let closed = client.close()?;
|
||||||
|
/// println!("closed: {closed:?}");
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
runtime: SessionMachine,
|
runtime: SessionMachine,
|
||||||
schedule: Option<ProbeSchedule>,
|
|
||||||
socket: UdpSocket,
|
socket: UdpSocket,
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
recv_buffer: Vec<u8>,
|
recv_buffer: Vec<u8>,
|
||||||
|
recv_timeout: Option<Duration>,
|
||||||
applied_traffic_class: Option<u8>,
|
applied_traffic_class: Option<u8>,
|
||||||
/// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
|
/// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
|
||||||
/// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
|
/// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
|
||||||
@@ -118,57 +80,62 @@ pub struct Client {
|
|||||||
/// unnecessary error-queue drains are skipped once it is known there is
|
/// unnecessary error-queue drains are skipped once it is known there is
|
||||||
/// nothing to drain.
|
/// nothing to drain.
|
||||||
tx_timestamping_enabled: bool,
|
tx_timestamping_enabled: bool,
|
||||||
#[cfg(test)]
|
|
||||||
test_hooks: ClientTestHooks,
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_reported_len: Option<usize>,
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_send_error: bool,
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_send_timestamps: Option<ProbeSendTimestamps>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// Resolve the configured server and create a connected UDP socket.
|
/// Resolve the endpoint and create a connected UDP socket.
|
||||||
///
|
///
|
||||||
/// This validates local configuration and prepares the open request, but it
|
/// This validates local configuration and prepares the open request, but it
|
||||||
/// does not contact the server. Call [`open`](Self::open) to perform the
|
/// does not contact the server. Call [`open`](Self::open) to perform the
|
||||||
/// IRTT open exchange.
|
/// IRTT open exchange.
|
||||||
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
|
///
|
||||||
validate_open_timeouts(&config.open_timeouts)?;
|
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
|
||||||
let remote = resolve_remote(&config)?;
|
/// IPv6 literals may be bracketed or unbracketed with the default port.
|
||||||
|
/// The reusable config contains no endpoint identity.
|
||||||
|
pub fn connect(endpoint: impl AsRef<str>, config: ClientConfig) -> Result<Self, ClientError> {
|
||||||
|
config.open.validate()?;
|
||||||
|
let remote = resolve_remote(endpoint.as_ref(), config.address_family)?;
|
||||||
let runtime = SessionMachine::new(config.clone(), remote)?;
|
let runtime = SessionMachine::new(config.clone(), remote)?;
|
||||||
let socket = connect_udp_socket(&config.socket_config, remote)?;
|
let socket = connect_udp_socket(&config.socket, remote, config.address_family)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
runtime,
|
runtime,
|
||||||
schedule: None,
|
|
||||||
socket,
|
socket,
|
||||||
remote,
|
remote,
|
||||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||||
|
recv_timeout: None,
|
||||||
applied_traffic_class: None,
|
applied_traffic_class: None,
|
||||||
tx_timestamping_enabled: false,
|
tx_timestamping_enabled: false,
|
||||||
#[cfg(test)]
|
|
||||||
test_hooks: ClientTestHooks::default(),
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_reported_len: None,
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_send_error: false,
|
|
||||||
#[cfg(test)]
|
|
||||||
probe_send_timestamps: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the blocking receive timeout used outside open attempts.
|
||||||
|
///
|
||||||
|
/// `None` (the default) lets `recv_once()` block indefinitely. A nonzero
|
||||||
|
/// timeout bounds one logical receive, including retries after EINTR.
|
||||||
|
/// `recv_available()` retains its bounded drain behavior. Open attempts
|
||||||
|
/// use only [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts) and
|
||||||
|
/// restore this setting on success or failure.
|
||||||
|
///
|
||||||
|
/// A zero duration is rejected by the socket; the previous setting is
|
||||||
|
/// retained when applying the new timeout fails.
|
||||||
|
pub fn set_recv_timeout(&mut self, timeout: Option<Duration>) -> Result<(), ClientError> {
|
||||||
|
self.socket.set_read_timeout(timeout)?;
|
||||||
|
self.recv_timeout = timeout;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Perform the IRTT open exchange.
|
/// Perform the IRTT open exchange.
|
||||||
///
|
///
|
||||||
/// On success, returns the negotiated open outcome and transitions the
|
/// On success, returns the negotiated open outcome and transitions the
|
||||||
/// client into either an open probe session or completed no-test state.
|
/// client into either an open probe session or completed no-test state.
|
||||||
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated,
|
/// Open attempts use [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts). Malformed or
|
||||||
/// or unauthenticated datagrams are ignored until the current attempt's
|
/// unrelated datagrams are ignored until the current attempt's absolute
|
||||||
/// absolute deadline, so one attempt may consume several datagrams without
|
/// deadline, so one attempt may consume several datagrams without
|
||||||
/// retransmitting. Silence or ignored traffic eventually produces
|
/// retransmitting. Silence or ignored traffic eventually produces
|
||||||
/// [`ClientError::OpenTimeout`], while authenticated incompatibility remains
|
/// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
|
||||||
/// terminal.
|
/// incompatible Open reply from the connected peer is terminal; when HMAC
|
||||||
|
/// is configured, that recognition additionally requires authentication.
|
||||||
///
|
///
|
||||||
/// When a trusted reply allocates a token but later negotiation or socket
|
/// When a trusted reply allocates a token but later negotiation or socket
|
||||||
/// preparation fails, the client sends a best-effort cleanup close and
|
/// preparation fails, the client sends a best-effort cleanup close and
|
||||||
@@ -177,8 +144,7 @@ impl Client {
|
|||||||
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||||
let result = self.open_transaction();
|
let result = self.open_transaction();
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
let _ =
|
let _ = self.socket.set_read_timeout(self.recv_timeout);
|
||||||
self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout);
|
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -188,19 +154,11 @@ impl Client {
|
|||||||
/// The close event means the client has sent its close packet and stopped
|
/// The close event means the client has sent its close packet and stopped
|
||||||
/// tracking the session locally; it is not a server acknowledgement. If the
|
/// tracking the session locally; it is not a server acknowledgement. If the
|
||||||
/// send fails, the negotiated DSCP is restored best-effort and the session
|
/// send fails, the negotiated DSCP is restored best-effort and the session
|
||||||
/// and probe schedule remain open.
|
/// remains open.
|
||||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
let prepared = self.runtime.prepare_close()?;
|
let prepared = self.runtime.prepare_close()?;
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_close_event_reserve.replace(false) {
|
|
||||||
events
|
|
||||||
.try_reserve(usize::MAX)
|
|
||||||
.map_err(|source| ClientError::AllocationFailed {
|
|
||||||
operation: "close event result",
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
events
|
events
|
||||||
.try_reserve(1)
|
.try_reserve(1)
|
||||||
.map_err(|source| ClientError::AllocationFailed {
|
.map_err(|source| ClientError::AllocationFailed {
|
||||||
@@ -211,94 +169,105 @@ impl Client {
|
|||||||
let expected_bytes = prepared.bytes.len();
|
let expected_bytes = prepared.bytes.len();
|
||||||
|
|
||||||
self.clear_close_dscp()?;
|
self.clear_close_dscp()?;
|
||||||
let bytes = match self.send_close_datagram(prepared.bytes) {
|
let bytes = match self.socket.send(prepared.bytes) {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.restore_dscp_best_effort(previous_traffic_class);
|
self.restore_dscp_best_effort(previous_traffic_class);
|
||||||
return Err(ClientError::Socket(err));
|
return Err(ClientError::Socket(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
#[cfg(not(test))]
|
|
||||||
let sent_at = ClientTimestamp::now();
|
let sent_at = ClientTimestamp::now();
|
||||||
#[cfg(test)]
|
|
||||||
let sent_at = self
|
|
||||||
.test_hooks
|
|
||||||
.close_sent_at
|
|
||||||
.take()
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
|
||||||
self.schedule = None;
|
|
||||||
self.applied_traffic_class = None;
|
|
||||||
|
|
||||||
|
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
||||||
|
|
||||||
|
self.applied_traffic_class = None;
|
||||||
validate_datagram_length(expected_bytes, bytes)?;
|
validate_datagram_length(expected_bytes, bytes)?;
|
||||||
events.push(event);
|
events.push(event);
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the monotonic deadline for the next probe send, if another probe
|
|
||||||
/// is scheduled.
|
|
||||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
|
||||||
if !self.runtime.is_open() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.schedule.as_ref()?.next_send_deadline()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the local timeout used to classify pending probes as lost.
|
/// Return the local timeout used to classify pending probes as lost.
|
||||||
pub fn probe_timeout(&self) -> Duration {
|
pub fn probe_timeout(&self) -> Duration {
|
||||||
self.runtime.probe_timeout()
|
self.runtime.probe_timeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one echo probe if the negotiated run is still active.
|
/// Send one echo probe now. The caller owns pacing and run duration.
|
||||||
///
|
///
|
||||||
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
|
/// The negotiated interval and duration do not gate low-level sends.
|
||||||
/// list when the run duration has elapsed and no further probe should be
|
/// A receipt means socket acceptance and infallible session commitment have
|
||||||
/// sent.
|
/// completed. Failures before acceptance return [`SendProbeError::NotCommitted`];
|
||||||
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
/// later validation or TX processing failures preserve the receipt in
|
||||||
self.send_probe_transaction(ProbeScheduleMode::CallerPaced)
|
/// [`SendProbeError::AfterCommit`].
|
||||||
|
pub fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||||
|
let remote = self.remote;
|
||||||
|
let (runtime, socket) = (&mut self.runtime, &self.socket);
|
||||||
|
let prepared = runtime
|
||||||
|
.prepare_probe()
|
||||||
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
|
let machine_preflight = runtime
|
||||||
|
.preflight_probe_commit(&prepared)
|
||||||
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
|
let expected_bytes = prepared.bytes.len();
|
||||||
|
|
||||||
|
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||||
|
// capacity/collision checks already done above) is finalized from
|
||||||
|
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||||
|
// the public measurement `sent_at` captured further below.
|
||||||
|
let send_anchor = ClientTimestamp::now();
|
||||||
|
let machine_commit = runtime
|
||||||
|
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||||
|
.map_err(SendProbeError::NotCommitted)?;
|
||||||
|
let send_call_start = Instant::now();
|
||||||
|
|
||||||
|
let bytes = match socket.send(&prepared.bytes) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) => {
|
||||||
|
runtime.invalidate_kernel_tx_correlation();
|
||||||
|
return Err(SendProbeError::NotCommitted(ClientError::Socket(error)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let send_finished_at = Instant::now();
|
||||||
|
|
||||||
|
// The public measurement timestamp: paired wall/monotonic sample
|
||||||
|
// captured immediately after the successful socket send completed.
|
||||||
|
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
||||||
|
// endpoint. The infallible machine commit below never fails.
|
||||||
|
let sent_at = ClientTimestamp::now();
|
||||||
|
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||||
|
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||||
|
let receipt = SendReceipt {
|
||||||
|
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),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(receipt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive and classify at most one datagram from the socket.
|
/// Receive and classify at most one datagram from the socket.
|
||||||
///
|
///
|
||||||
/// Returns an empty event list when the socket read would block or times
|
/// Returns an empty event list when the socket read would block or times
|
||||||
/// out. Malformed or unrelated datagrams are reported as warning events.
|
/// out. Malformed or unrelated datagrams are reported as warning events.
|
||||||
|
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||||
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.socket
|
self.runtime.ensure_open()?;
|
||||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
self.socket.set_read_timeout(self.recv_timeout)?;
|
||||||
self.recv_once_inner()
|
self.recv_once_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive one datagram into `self.recv_buffer`. In test builds, a
|
|
||||||
/// pending `inject_recv_interrupted` count reports
|
|
||||||
/// `io::ErrorKind::Interrupted` instead of performing a real receive,
|
|
||||||
/// deterministically exercising interrupted-syscall retry behavior.
|
|
||||||
fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
|
||||||
if remaining > 0 {
|
|
||||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
|
||||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recv_datagram(&self.socket, &mut self.recv_buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
|
|
||||||
/// buffer instead of `self.recv_buffer` (used before the negotiated
|
|
||||||
/// receive buffer size is known, i.e. during Open).
|
|
||||||
fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
|
||||||
if remaining > 0 {
|
|
||||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
|
||||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recv_datagram(&self.socket, buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
|
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
@@ -314,11 +283,8 @@ impl Client {
|
|||||||
datagram.received_at,
|
datagram.received_at,
|
||||||
datagram.meta,
|
datagram.meta,
|
||||||
)?;
|
)?;
|
||||||
if self.runtime.is_peer_closed() {
|
if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
|
||||||
self.schedule = None;
|
self.applied_traffic_class = None;
|
||||||
if self.clear_close_dscp().is_ok() {
|
|
||||||
self.applied_traffic_class = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
@@ -339,7 +305,7 @@ impl Client {
|
|||||||
fn recv_datagram_retrying_interrupted(
|
fn recv_datagram_retrying_interrupted(
|
||||||
&mut self,
|
&mut self,
|
||||||
) -> Result<Option<ReceivedDatagram>, ClientError> {
|
) -> Result<Option<ReceivedDatagram>, ClientError> {
|
||||||
let configured_timeout = self.runtime.config().socket_config.recv_timeout;
|
let configured_timeout = self.recv_timeout;
|
||||||
let deadline = match configured_timeout {
|
let deadline = match configured_timeout {
|
||||||
Some(timeout) => Some(
|
Some(timeout) => Some(
|
||||||
Instant::now()
|
Instant::now()
|
||||||
@@ -351,7 +317,7 @@ impl Client {
|
|||||||
let mut timeout_narrowed = false;
|
let mut timeout_narrowed = false;
|
||||||
|
|
||||||
let result = loop {
|
let result = loop {
|
||||||
match self.recv_datagram_once() {
|
match recv_datagram(&self.socket, &mut self.recv_buffer) {
|
||||||
Ok(datagram) => break Ok(Some(datagram)),
|
Ok(datagram) => break Ok(Some(datagram)),
|
||||||
Err(err)
|
Err(err)
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -390,9 +356,11 @@ impl Client {
|
|||||||
|
|
||||||
/// Receive and classify datagrams until a receive produces no events or the
|
/// Receive and classify datagrams until a receive produces no events or the
|
||||||
/// receive budget is exhausted.
|
/// receive budget is exhausted.
|
||||||
|
///
|
||||||
|
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||||
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
||||||
self.socket
|
self.runtime.ensure_open()?;
|
||||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
self.socket.set_read_timeout(self.recv_timeout)?;
|
||||||
let mut all_events = Vec::new();
|
let mut all_events = Vec::new();
|
||||||
for _ in 0..budget.max_packets {
|
for _ in 0..budget.max_packets {
|
||||||
let events = self.recv_once_inner()?;
|
let events = self.recv_once_inner()?;
|
||||||
@@ -424,17 +392,21 @@ impl Client {
|
|||||||
self.runtime.poll_timeouts_at(now)
|
self.runtime.poll_timeouts_at(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether the current run has completed.
|
/// Accepted semantics and exact peer parameters of the currently live session.
|
||||||
///
|
///
|
||||||
/// A normal run is complete once no more probes will be sent and all
|
/// Returns `None` before Open, after Close, and after no-test completion.
|
||||||
/// pending probes have either replied or timed out. No-test and closed
|
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
|
||||||
/// sessions are also considered complete.
|
self.runtime.negotiation()
|
||||||
pub fn is_run_complete(&self) -> bool {
|
}
|
||||||
self.runtime.is_terminal()
|
|
||||||
|| self
|
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||||
.schedule
|
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||||
.as_ref()
|
self.runtime.next_probe_timeout_deadline()
|
||||||
.is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty())
|
}
|
||||||
|
|
||||||
|
/// Whether any sent probes still await a reply or timeout.
|
||||||
|
pub fn has_pending_probes(&self) -> bool {
|
||||||
|
!self.runtime.pending_is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether the session was closed by a peer close-flagged reply.
|
/// Return whether the session was closed by a peer close-flagged reply.
|
||||||
@@ -447,130 +419,13 @@ impl Client {
|
|||||||
self.runtime.is_peer_closed()
|
self.runtime.is_peer_closed()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn send_managed_probe(
|
|
||||||
&mut self,
|
|
||||||
scheduled_at: Instant,
|
|
||||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
|
||||||
self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_probe_transaction(
|
|
||||||
&mut self,
|
|
||||||
mode: ProbeScheduleMode,
|
|
||||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
|
||||||
self.runtime.ensure_open()?;
|
|
||||||
#[cfg(test)]
|
|
||||||
let test_timestamps = self.probe_send_timestamps.take();
|
|
||||||
#[cfg(test)]
|
|
||||||
let fail_send = std::mem::take(&mut self.probe_send_error);
|
|
||||||
let remote = self.remote;
|
|
||||||
let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
|
|
||||||
let schedule = schedule
|
|
||||||
.as_mut()
|
|
||||||
.expect("open sessions always have a probe schedule");
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let permission_at = Instant::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let permission_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.permission_at)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
if !schedule.permit_probe_at(permission_at) {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(prepared) = runtime.prepare_probe()? else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
|
|
||||||
let schedule_commit = match mode {
|
|
||||||
ProbeScheduleMode::CallerPaced => {
|
|
||||||
schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
|
||||||
ProbeScheduleMode::Managed { scheduled_at } => {
|
|
||||||
schedule.preflight_managed_commit(scheduled_at, permission_at)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut events = Vec::new();
|
|
||||||
events
|
|
||||||
.try_reserve(1)
|
|
||||||
.map_err(|source| ClientError::AllocationFailed {
|
|
||||||
operation: "probe event result",
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let expected_bytes = prepared.bytes.len();
|
|
||||||
let scheduled_at = schedule_commit.scheduled_at;
|
|
||||||
#[cfg(test)]
|
|
||||||
let reported_bytes = self.probe_reported_len.take();
|
|
||||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
|
||||||
// capacity/collision checks already done above) is finalized from
|
|
||||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
|
||||||
// the public measurement `sent_at` captured further below.
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_anchor = ClientTimestamp::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_anchor = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_anchor)
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_call_start = Instant::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_call_start = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_call_start)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
#[cfg(test)]
|
|
||||||
if fail_send {
|
|
||||||
return Err(ClientError::Socket(io::Error::other(
|
|
||||||
"injected probe send failure",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let bytes = socket.send(&prepared.bytes)?;
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let send_finished_at = Instant::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let send_finished_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.send_finished_at)
|
|
||||||
.unwrap_or_else(Instant::now);
|
|
||||||
// The public measurement timestamp: paired wall/monotonic sample
|
|
||||||
// captured immediately after the successful socket send completed.
|
|
||||||
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
|
||||||
// endpoint. The infallible machine/schedule commit below never fails.
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let sent_at = ClientTimestamp::now();
|
|
||||||
#[cfg(test)]
|
|
||||||
let sent_at = test_timestamps
|
|
||||||
.map(|timestamps| timestamps.sent_at)
|
|
||||||
.unwrap_or_else(ClientTimestamp::now);
|
|
||||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
|
||||||
schedule.commit(schedule_commit);
|
|
||||||
|
|
||||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
|
||||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
|
||||||
#[cfg(test)]
|
|
||||||
let bytes = reported_bytes.unwrap_or(bytes);
|
|
||||||
validate_datagram_length(expected_bytes, bytes)?;
|
|
||||||
self.drain_tx_timestamps()?;
|
|
||||||
|
|
||||||
events.push(echo_sent_event(
|
|
||||||
remote,
|
|
||||||
sent,
|
|
||||||
send_call,
|
|
||||||
scheduled_at,
|
|
||||||
timer_error,
|
|
||||||
));
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
|
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||||
let request = self.runtime.prepare_open_request()?;
|
let request = self.runtime.prepare_open_request()?;
|
||||||
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
|
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
|
||||||
let attempt_count = self.runtime.config().open_timeouts.len();
|
let attempt_count = self.runtime.config().open.timeouts.len();
|
||||||
|
|
||||||
for attempt in 0..attempt_count {
|
for attempt in 0..attempt_count {
|
||||||
let timeout = self.runtime.config().open_timeouts[attempt];
|
let timeout = self.runtime.config().open.timeouts[attempt];
|
||||||
let deadline = Instant::now()
|
let deadline = Instant::now()
|
||||||
.checked_add(timeout)
|
.checked_add(timeout)
|
||||||
.ok_or(ClientError::DurationOverflow)?;
|
.ok_or(ClientError::DurationOverflow)?;
|
||||||
@@ -587,7 +442,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
self.socket.set_read_timeout(Some(remaining))?;
|
self.socket.set_read_timeout(Some(remaining))?;
|
||||||
|
|
||||||
let datagram = match self.recv_datagram_once_into(&mut buf) {
|
let datagram = match recv_datagram(&self.socket, &mut buf) {
|
||||||
Ok(datagram) => datagram,
|
Ok(datagram) => datagram,
|
||||||
// WouldBlock/TimedOut mean the attempt's own deadline
|
// WouldBlock/TimedOut mean the attempt's own deadline
|
||||||
// elapsed; Interrupted (EINTR) means the syscall was
|
// elapsed; Interrupted (EINTR) means the syscall was
|
||||||
@@ -630,7 +485,7 @@ impl Client {
|
|||||||
return Err(failure.primary);
|
return Err(failure.primary);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let prepared = match self.prepare_client_open(machine, datagram.received_at) {
|
let prepared = match self.prepare_client_open(machine) {
|
||||||
Ok(prepared) => prepared,
|
Ok(prepared) => prepared,
|
||||||
Err(failure) => {
|
Err(failure) => {
|
||||||
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
|
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
|
||||||
@@ -647,51 +502,28 @@ impl Client {
|
|||||||
fn prepare_client_open(
|
fn prepare_client_open(
|
||||||
&self,
|
&self,
|
||||||
machine: PreparedOpenAcceptance,
|
machine: PreparedOpenAcceptance,
|
||||||
opened_at: ClientTimestamp,
|
|
||||||
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
|
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
|
||||||
let Some(negotiated) = machine.normal_negotiated() else {
|
let Some(negotiated) = machine.normal_negotiated() else {
|
||||||
return Ok(PreparedClientOpen {
|
return Ok(PreparedClientOpen {
|
||||||
machine,
|
machine,
|
||||||
schedule: None,
|
|
||||||
recv_buffer_len: None,
|
recv_buffer_len: None,
|
||||||
negotiated_traffic_class: None,
|
negotiated_traffic_class: None,
|
||||||
previous_traffic_class: self.applied_traffic_class,
|
previous_traffic_class: self.applied_traffic_class,
|
||||||
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
|
post_open_recv_timeout: self.recv_timeout,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
|
||||||
Ok(schedule) => schedule,
|
|
||||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
|
||||||
};
|
|
||||||
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
|
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
|
||||||
Ok(size) => size,
|
Ok(size) => size,
|
||||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
let recv_buffer_len = self
|
|
||||||
.test_hooks
|
|
||||||
.recv_buffer_len_override
|
|
||||||
.get()
|
|
||||||
.unwrap_or(recv_buffer_len);
|
|
||||||
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
|
|
||||||
Ok(traffic_class) => traffic_class,
|
|
||||||
Err(_) => {
|
|
||||||
return Err(Box::new(PreparedClientOpenFailure {
|
|
||||||
primary: ClientError::InvalidConfig {
|
|
||||||
reason: "negotiated dscp must be in range 0..=255".to_owned(),
|
|
||||||
},
|
|
||||||
machine,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
|
||||||
Ok(PreparedClientOpen {
|
Ok(PreparedClientOpen {
|
||||||
machine,
|
machine,
|
||||||
schedule: Some(schedule),
|
|
||||||
recv_buffer_len: Some(recv_buffer_len),
|
recv_buffer_len: Some(recv_buffer_len),
|
||||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||||
previous_traffic_class: self.applied_traffic_class,
|
previous_traffic_class: self.applied_traffic_class,
|
||||||
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
|
post_open_recv_timeout: self.recv_timeout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +533,6 @@ impl Client {
|
|||||||
) -> Result<OpenOutcome, ClientError> {
|
) -> Result<OpenOutcome, ClientError> {
|
||||||
let PreparedClientOpen {
|
let PreparedClientOpen {
|
||||||
machine,
|
machine,
|
||||||
schedule,
|
|
||||||
recv_buffer_len,
|
recv_buffer_len,
|
||||||
negotiated_traffic_class,
|
negotiated_traffic_class,
|
||||||
previous_traffic_class,
|
previous_traffic_class,
|
||||||
@@ -722,17 +553,15 @@ impl Client {
|
|||||||
}
|
}
|
||||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||||
|
|
||||||
#[cfg(test)]
|
if let Err(primary) =
|
||||||
self.test_hooks
|
apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
|
||||||
.prepared_active_session_before_dscp
|
{
|
||||||
.set(machine.has_prepared_active_session());
|
|
||||||
if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
|
|
||||||
self.recv_buffer.truncate(previous_len);
|
self.recv_buffer.truncate(previous_len);
|
||||||
self.restore_dscp_best_effort(previous_traffic_class);
|
self.restore_dscp_best_effort(previous_traffic_class);
|
||||||
self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
|
self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
|
||||||
return Err(primary);
|
return Err(primary);
|
||||||
}
|
}
|
||||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||||
let primary = ClientError::ReadTimeoutRestore { source };
|
let primary = ClientError::ReadTimeoutRestore { source };
|
||||||
self.recv_buffer.truncate(previous_len);
|
self.recv_buffer.truncate(previous_len);
|
||||||
self.restore_dscp_best_effort(previous_traffic_class);
|
self.restore_dscp_best_effort(previous_traffic_class);
|
||||||
@@ -741,17 +570,16 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let outcome = self.runtime.commit_open(machine);
|
let outcome = self.runtime.commit_open(machine);
|
||||||
self.schedule = schedule;
|
|
||||||
self.applied_traffic_class = Some(negotiated_traffic_class);
|
self.applied_traffic_class = Some(negotiated_traffic_class);
|
||||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
} else {
|
} else {
|
||||||
debug_assert!(schedule.is_none());
|
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
|
||||||
return Err(ClientError::ReadTimeoutRestore { source });
|
return Err(ClientError::ReadTimeoutRestore { source });
|
||||||
}
|
}
|
||||||
let outcome = self.runtime.commit_open(machine);
|
let outcome = self.runtime.commit_open(machine);
|
||||||
self.schedule = None;
|
|
||||||
self.applied_traffic_class = None;
|
self.applied_traffic_class = None;
|
||||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
@@ -776,10 +604,6 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_dscp_restore.replace(false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = match previous_traffic_class {
|
let _ = match previous_traffic_class {
|
||||||
Some(traffic_class) => {
|
Some(traffic_class) => {
|
||||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
||||||
@@ -789,86 +613,14 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_close_dscp_clear.replace(false) {
|
|
||||||
return Err(ClientError::SocketOption {
|
|
||||||
operation: "clear negotiated DSCP",
|
|
||||||
remote: self.remote,
|
|
||||||
source: io::Error::other("injected negotiated DSCP clear failure"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
clear_dscp_on_socket(&self.socket, self.remote)
|
clear_dscp_on_socket(&self.socket, self.remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
|
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
|
||||||
if let Some(packet) = packet {
|
if let Some(packet) = packet {
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = self.socket.send(packet);
|
let _ = self.socket.send(packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_open_dscp.replace(false) {
|
|
||||||
return Err(ClientError::SocketOption {
|
|
||||||
operation: "set negotiated DSCP",
|
|
||||||
remote: self.remote,
|
|
||||||
source: io::Error::other("injected negotiated DSCP failure"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
|
|
||||||
#[cfg(test)]
|
|
||||||
self.test_hooks.last_restored_read_timeout.set(timeout);
|
|
||||||
#[cfg(test)]
|
|
||||||
if self.test_hooks.fail_open_timeout_restore.replace(false) {
|
|
||||||
return Err(io::Error::other(
|
|
||||||
"injected configured read timeout restoration failure",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.socket.set_read_timeout(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
self.test_hooks
|
|
||||||
.close_send_attempts
|
|
||||||
.set(self.test_hooks.close_send_attempts.get() + 1);
|
|
||||||
if self.test_hooks.fail_close_send.replace(false) {
|
|
||||||
return Err(io::Error::other("injected close send failure"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let bytes = self.socket.send(packet)?;
|
|
||||||
#[cfg(test)]
|
|
||||||
if let Some(reported) = self.test_hooks.close_reported_len.take() {
|
|
||||||
return Ok(reported);
|
|
||||||
}
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn echo_sent_event(
|
|
||||||
remote: SocketAddr,
|
|
||||||
sent: ProbeSent,
|
|
||||||
send_call: Duration,
|
|
||||||
scheduled_at: Instant,
|
|
||||||
timer_error: Duration,
|
|
||||||
) -> ClientEvent {
|
|
||||||
ClientEvent::EchoSent {
|
|
||||||
seq: sent.seq,
|
|
||||||
remote,
|
|
||||||
scheduled_at,
|
|
||||||
sent_at: sent.sent_at,
|
|
||||||
bytes: sent.bytes,
|
|
||||||
send_call,
|
|
||||||
timer_error,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
|
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
|
||||||
@@ -878,47 +630,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
|
|||||||
Err(ClientError::DatagramLengthMismatch { expected, actual })
|
Err(ClientError::DatagramLengthMismatch { expected, actual })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Client {
|
|
||||||
fn send_probe_at(
|
|
||||||
&mut self,
|
|
||||||
timestamps: ProbeSendTimestamps,
|
|
||||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
|
||||||
self.probe_send_timestamps = Some(timestamps);
|
|
||||||
self.send_probe()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_managed_probe_at(
|
|
||||||
&mut self,
|
|
||||||
scheduled_at: Instant,
|
|
||||||
timestamps: ProbeSendTimestamps,
|
|
||||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
|
||||||
self.probe_send_timestamps = Some(timestamps);
|
|
||||||
self.send_managed_probe(scheduled_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn bounded_receive_timeout(
|
|
||||||
deadline: Option<Instant>,
|
|
||||||
configured_timeout: Option<Duration>,
|
|
||||||
max_wait: Duration,
|
|
||||||
now: Instant,
|
|
||||||
) -> Option<Duration> {
|
|
||||||
if max_wait.is_zero() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
|
|
||||||
if let Some(deadline) = deadline {
|
|
||||||
let remaining = deadline.checked_duration_since(now)?;
|
|
||||||
if remaining.is_zero() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
timeout = timeout.min(remaining);
|
|
||||||
}
|
|
||||||
(!timeout.is_zero()).then_some(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::{error::ClientError, session::NegotiatedParams};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct ProbeSchedule {
|
|
||||||
start_at: Instant,
|
|
||||||
end_at: Option<Instant>,
|
|
||||||
interval: Duration,
|
|
||||||
next_send_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct ScheduleCommit {
|
|
||||||
pub(crate) scheduled_at: Instant,
|
|
||||||
pub(crate) next_send_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProbeSchedule {
|
|
||||||
pub(crate) fn new(
|
|
||||||
start_at: Instant,
|
|
||||||
negotiated: &NegotiatedParams,
|
|
||||||
) -> Result<Self, ClientError> {
|
|
||||||
let interval_ns = u64::try_from(negotiated.params.interval_ns)
|
|
||||||
.expect("validated positive negotiated interval");
|
|
||||||
let interval = Duration::from_nanos(interval_ns);
|
|
||||||
let end_at = if negotiated.params.duration_ns > 0 {
|
|
||||||
let duration_ns = u64::try_from(negotiated.params.duration_ns)
|
|
||||||
.expect("validated positive negotiated duration");
|
|
||||||
Some(
|
|
||||||
start_at
|
|
||||||
.checked_add(Duration::from_nanos(duration_ns))
|
|
||||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
|
||||||
reason: "duration is too large to schedule".to_owned(),
|
|
||||||
})?,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
interval,
|
|
||||||
next_send_at: Some(start_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
|
||||||
self.next_send_at
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tokio")]
|
|
||||||
pub(crate) fn interval(&self) -> Duration {
|
|
||||||
self.interval
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
|
||||||
if self.next_send_at.is_none() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.end_at.is_some_and(|end| now >= end) {
|
|
||||||
self.next_send_at = None;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn preflight_caller_commit(
|
|
||||||
&self,
|
|
||||||
next_packets_sent: u64,
|
|
||||||
) -> Result<ScheduleCommit, ClientError> {
|
|
||||||
let scheduled_at = self
|
|
||||||
.next_send_at
|
|
||||||
.expect("a caller schedule commit requires a permitted probe");
|
|
||||||
let interval_ns =
|
|
||||||
u64::try_from(self.interval.as_nanos()).map_err(|_| ClientError::DurationOverflow)?;
|
|
||||||
let next_send_at = next_probe_deadline(self.start_at, interval_ns, next_packets_sent)?;
|
|
||||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", test))]
|
|
||||||
pub(crate) fn preflight_managed_commit(
|
|
||||||
&self,
|
|
||||||
scheduled_at: Instant,
|
|
||||||
permission_at: Instant,
|
|
||||||
) -> Result<ScheduleCommit, ClientError> {
|
|
||||||
let (scheduled_at, next_send_at, _) =
|
|
||||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
|
||||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
|
||||||
self.next_send_at = commit.next_send_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_finished(&self) -> bool {
|
|
||||||
self.next_send_at.is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_commit(&self, scheduled_at: Instant, next_send_at: Instant) -> ScheduleCommit {
|
|
||||||
ScheduleCommit {
|
|
||||||
scheduled_at,
|
|
||||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(next_send_at)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_probe_deadline(
|
|
||||||
start: Instant,
|
|
||||||
interval_ns: u64,
|
|
||||||
packets_sent: u64,
|
|
||||||
) -> Result<Instant, ClientError> {
|
|
||||||
let offset_ns = interval_ns
|
|
||||||
.checked_mul(packets_sent)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?;
|
|
||||||
start
|
|
||||||
.checked_add(Duration::from_nanos(offset_ns))
|
|
||||||
.ok_or(ClientError::DurationOverflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", test))]
|
|
||||||
pub(crate) fn advance_cadence(
|
|
||||||
deadline: Instant,
|
|
||||||
interval: Duration,
|
|
||||||
now: Instant,
|
|
||||||
) -> Result<(Instant, Instant, u128), ClientError> {
|
|
||||||
if interval.is_zero() {
|
|
||||||
return Err(ClientError::InvalidConfig {
|
|
||||||
reason: "probe interval must be greater than zero".to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let elapsed_slots = now
|
|
||||||
.checked_duration_since(deadline)
|
|
||||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
|
||||||
let scheduled_offset = duration_from_nanos(
|
|
||||||
interval
|
|
||||||
.as_nanos()
|
|
||||||
.checked_mul(elapsed_slots)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?,
|
|
||||||
)?;
|
|
||||||
let next_offset = duration_from_nanos(
|
|
||||||
interval
|
|
||||||
.as_nanos()
|
|
||||||
.checked_mul(
|
|
||||||
elapsed_slots
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?,
|
|
||||||
)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?,
|
|
||||||
)?;
|
|
||||||
let scheduled_at = deadline
|
|
||||||
.checked_add(scheduled_offset)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?;
|
|
||||||
let next_at = deadline
|
|
||||||
.checked_add(next_offset)
|
|
||||||
.ok_or(ClientError::DurationOverflow)?;
|
|
||||||
Ok((scheduled_at, next_at, elapsed_slots))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", test))]
|
|
||||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
|
||||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
|
||||||
let seconds =
|
|
||||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
|
||||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
|
||||||
.expect("nanosecond remainder is always less than one second");
|
|
||||||
Ok(Duration::new(seconds, subsec_nanos))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
|
||||||
left.checked_duration_since(right)
|
|
||||||
.or_else(|| right.checked_duration_since(left))
|
|
||||||
.unwrap_or(Duration::ZERO)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use irtt_proto::Params;
|
|
||||||
|
|
||||||
fn negotiated(interval_ns: i64, duration_ns: i64) -> NegotiatedParams {
|
|
||||||
NegotiatedParams {
|
|
||||||
params: Params {
|
|
||||||
interval_ns,
|
|
||||||
duration_ns,
|
|
||||||
..Params::default()
|
|
||||||
},
|
|
||||||
restrictions: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initial_deadline_is_the_opening_time() {
|
|
||||||
let opened_at = Instant::now();
|
|
||||||
|
|
||||||
let schedule = ProbeSchedule::new(opened_at, &negotiated(10_000_000, 0)).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(schedule.next_send_deadline(), Some(opened_at));
|
|
||||||
assert!(!schedule.is_finished());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn caller_preflight_keeps_absolute_cadence_without_mutation() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
|
||||||
|
|
||||||
let commit = schedule.preflight_caller_commit(3).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(commit.scheduled_at, start);
|
|
||||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(30)));
|
|
||||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn caller_commit_advances_exactly_one_cadence_slot() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
|
||||||
|
|
||||||
let commit = schedule.preflight_caller_commit(1).unwrap();
|
|
||||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
|
||||||
|
|
||||||
schedule.commit(commit);
|
|
||||||
assert_eq!(
|
|
||||||
schedule.next_send_deadline(),
|
|
||||||
Some(start + Duration::from_millis(10))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finite_duration_uses_an_exclusive_end_boundary() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
|
||||||
|
|
||||||
let first = schedule.preflight_caller_commit(1).unwrap();
|
|
||||||
schedule.commit(first);
|
|
||||||
assert_eq!(
|
|
||||||
schedule.next_send_deadline(),
|
|
||||||
Some(start + Duration::from_millis(10))
|
|
||||||
);
|
|
||||||
|
|
||||||
let second = schedule.preflight_caller_commit(2).unwrap();
|
|
||||||
schedule.commit(second);
|
|
||||||
|
|
||||||
assert!(schedule.is_finished());
|
|
||||||
assert_eq!(schedule.next_send_deadline(), None);
|
|
||||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn permit_at_exclusive_end_finishes_schedule_without_a_send() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
|
||||||
|
|
||||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
|
||||||
assert!(schedule.is_finished());
|
|
||||||
assert_eq!(schedule.next_send_deadline(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn managed_preflight_skips_missed_slots_without_mutation() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
|
||||||
let sent_at = start + Duration::from_millis(45);
|
|
||||||
|
|
||||||
let commit = schedule.preflight_managed_commit(start, sent_at).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(commit.scheduled_at, start + Duration::from_millis(40));
|
|
||||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(50)));
|
|
||||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delayed_managed_commit_does_not_replay_catch_up_slots() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
|
||||||
let delayed_send = start + Duration::from_millis(45);
|
|
||||||
|
|
||||||
let commit = schedule
|
|
||||||
.preflight_managed_commit(start, delayed_send)
|
|
||||||
.unwrap();
|
|
||||||
schedule.commit(commit);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
schedule.next_send_deadline(),
|
|
||||||
Some(start + Duration::from_millis(50))
|
|
||||||
);
|
|
||||||
assert!(schedule.next_send_deadline().unwrap() > delayed_send);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deadline_arithmetic_overflow_is_detected_before_commit() {
|
|
||||||
let start = Instant::now();
|
|
||||||
let schedule = ProbeSchedule::new(start, &negotiated(2, 0)).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
schedule.preflight_caller_commit(u64::MAX),
|
|
||||||
Err(ClientError::DurationOverflow)
|
|
||||||
));
|
|
||||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timer_error_is_adapter_owned() {
|
|
||||||
let scheduled_at = Instant::now();
|
|
||||||
let sent_at = scheduled_at + Duration::from_millis(7);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
instant_abs_diff(sent_at, scheduled_at),
|
|
||||||
Duration::from_millis(7)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_config_default() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
assert_eq!(config.duration, Some(Duration::from_secs(3)));
|
|
||||||
assert_eq!(config.interval, Duration::from_secs(1));
|
|
||||||
assert_eq!(config.length, 0);
|
|
||||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
|
||||||
assert_eq!(config.stamp_at, StampAt::Both);
|
|
||||||
assert_eq!(config.clock, Clock::Both);
|
|
||||||
assert_eq!(config.dscp, 0);
|
|
||||||
assert_eq!(config.hmac_key, None);
|
|
||||||
assert_eq!(config.server_fill, None);
|
|
||||||
assert_eq!(config.open_timeouts, DEFAULT_OPEN_TIMEOUTS);
|
|
||||||
assert_eq!(config.run_mode, RunMode::Normal);
|
|
||||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
|
||||||
assert_eq!(config.probe_timeout, Duration::from_secs(4));
|
|
||||||
assert_eq!(config.max_pending_probes, 4096);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_maps_compatibility_fields() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: Some(Duration::from_secs(5)),
|
|
||||||
interval: Duration::from_millis(250),
|
|
||||||
length: 1472,
|
|
||||||
received_stats: ReceivedStats::Window,
|
|
||||||
stamp_at: StampAt::Midpoint,
|
|
||||||
clock: Clock::Wall,
|
|
||||||
dscp: 46,
|
|
||||||
hmac_key: Some(b"secret".to_vec()),
|
|
||||||
server_fill: Some("rand".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
|
|
||||||
assert_eq!(params.duration_ns, 5_000_000_000);
|
|
||||||
assert_eq!(params.interval_ns, 250_000_000);
|
|
||||||
assert_eq!(params.length, 1472);
|
|
||||||
assert_eq!(params.received_stats, ReceivedStats::Window);
|
|
||||||
assert_eq!(params.stamp_at, StampAt::Midpoint);
|
|
||||||
assert_eq!(params.clock, Clock::Wall);
|
|
||||||
assert_eq!(
|
|
||||||
params.dscp, 184,
|
|
||||||
"config DSCP codepoint 46 must be shifted into raw wire byte 184"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
|
|
||||||
Some("rand")
|
|
||||||
);
|
|
||||||
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_accepts_boundary_values() {
|
|
||||||
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
|
||||||
let config = ClientConfig {
|
|
||||||
length,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
params_from_config(&config).unwrap().length,
|
|
||||||
i64::from(length)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 63,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
params_from_config(&config).unwrap().dscp,
|
|
||||||
252,
|
|
||||||
"boundary codepoint 63 must be shifted into raw wire byte 252"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_encodes_continuous_duration_as_zero() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: None,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_from_config_rejects_invalid_values() {
|
|
||||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
|
||||||
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
"oversized UDP payload",
|
|
||||||
ClientConfig {
|
|
||||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"packet length",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"zero finite duration",
|
|
||||||
ClientConfig {
|
|
||||||
duration: Some(Duration::ZERO),
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"duration must be greater than zero; use None for continuous mode",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"zero interval",
|
|
||||||
ClientConfig {
|
|
||||||
interval: Duration::ZERO,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"interval must be greater than zero",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"duration nanosecond overflow",
|
|
||||||
ClientConfig {
|
|
||||||
duration: Some(too_large),
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"duration is too large to encode as nanoseconds",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"interval nanosecond overflow",
|
|
||||||
ClientConfig {
|
|
||||||
interval: too_large,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"interval is too large to encode as nanoseconds",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"invalid DSCP codepoint",
|
|
||||||
ClientConfig {
|
|
||||||
dscp: 64,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"dscp",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
// The unspecified clock is the absent-tag wire default, not a
|
|
||||||
// client mode; a client that needs timestamps must name a clock.
|
|
||||||
"unspecified clock",
|
|
||||||
ClientConfig {
|
|
||||||
clock: Clock::Unspecified,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"clock must be wall, monotonic, or both",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"empty server fill",
|
|
||||||
ClientConfig {
|
|
||||||
server_fill: Some("".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"server_fill",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"oversized server fill",
|
|
||||||
ClientConfig {
|
|
||||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
"server_fill",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (name, config, expected_reason) in cases {
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
params_from_config(&config),
|
|
||||||
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
|
||||||
),
|
|
||||||
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn minimum_open_timeout_under_200ms_is_rejected() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(199)],
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::OpenTimeoutTooSmall { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_open_timeouts_is_rejected() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![],
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::NoOpenTimeouts)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::socket_options::socket_traffic_class;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(not(any(
|
|
||||||
target_os = "fuchsia",
|
|
||||||
target_os = "redox",
|
|
||||||
target_os = "solaris",
|
|
||||||
target_os = "illumos",
|
|
||||||
target_os = "haiku",
|
|
||||||
)))]
|
|
||||||
fn normal_open_applies_negotiated_traffic_class_after_open_and_close_clears_it() {
|
|
||||||
let mut params = default_params();
|
|
||||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF): a
|
|
||||||
// compliant server echoes back the raw byte it received, unchanged.
|
|
||||||
params.dscp = 184;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.dscp = 46;
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
|
||||||
184
|
|
||||||
);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(not(any(
|
|
||||||
target_os = "fuchsia",
|
|
||||||
target_os = "redox",
|
|
||||||
target_os = "solaris",
|
|
||||||
target_os = "illumos",
|
|
||||||
target_os = "haiku",
|
|
||||||
)))]
|
|
||||||
fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
|
|
||||||
let mut returned = default_params();
|
|
||||||
returned.dscp = 0;
|
|
||||||
let server = open_success_server(returned);
|
|
||||||
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.dscp = 46;
|
|
||||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
let negotiated = assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(negotiated.params.dscp, 0);
|
|
||||||
assert_eq!(
|
|
||||||
negotiated.restrictions,
|
|
||||||
vec![crate::NegotiationRestriction::DscpChanged {
|
|
||||||
requested: 46,
|
|
||||||
negotiated: 0,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(not(any(
|
|
||||||
target_os = "fuchsia",
|
|
||||||
target_os = "redox",
|
|
||||||
target_os = "solaris",
|
|
||||||
target_os = "illumos",
|
|
||||||
target_os = "haiku",
|
|
||||||
)))]
|
|
||||||
fn failed_close_send_restores_negotiated_traffic_class_and_keeps_session_open() {
|
|
||||||
let mut params = default_params();
|
|
||||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
|
||||||
params.dscp = 184;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.dscp = 46;
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.test_hooks.fail_close_send.set(true);
|
|
||||||
|
|
||||||
let error = client.close().unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, ClientError::Socket(_)));
|
|
||||||
assert!(client.runtime.is_open());
|
|
||||||
assert!(client.schedule.is_some());
|
|
||||||
assert_eq!(client.applied_traffic_class, Some(184));
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
|
||||||
184
|
|
||||||
);
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_close_keeps_send_error_primary_when_dscp_restoration_also_fails() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.test_hooks.fail_close_send.set(true);
|
|
||||||
client.test_hooks.fail_dscp_restore.set(true);
|
|
||||||
|
|
||||||
let error = client.close().unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, ClientError::Socket(_)));
|
|
||||||
assert!(error.to_string().contains("injected close send failure"));
|
|
||||||
assert!(client.runtime.is_open());
|
|
||||||
assert!(client.schedule.is_some());
|
|
||||||
assert_eq!(client.applied_traffic_class, Some(0));
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(not(any(
|
|
||||||
target_os = "fuchsia",
|
|
||||||
target_os = "redox",
|
|
||||||
target_os = "solaris",
|
|
||||||
target_os = "illumos",
|
|
||||||
target_os = "haiku",
|
|
||||||
)))]
|
|
||||||
fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
|
|
||||||
let mut params = default_params();
|
|
||||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
|
||||||
params.dscp = 184;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let (request, _) = recv_request(&socket, &tx);
|
|
||||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&echo_reply_packet_with_flags(
|
|
||||||
TOKEN,
|
|
||||||
seq,
|
|
||||||
¶ms,
|
|
||||||
&TimestampFields::default(),
|
|
||||||
None,
|
|
||||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
|
||||||
),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.dscp = 46;
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
events.as_slice(),
|
|
||||||
[
|
|
||||||
ClientEvent::EchoReply { .. },
|
|
||||||
ClientEvent::SessionClosed { .. }
|
|
||||||
]
|
|
||||||
));
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
assert_eq!(
|
|
||||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_open_success() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.hmac_key = Some(key.clone());
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (request, peer) = recv_request(&socket, &tx);
|
|
||||||
verify_packet_hmac(&key, &request).unwrap();
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.hmac_key = Some(key.clone());
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
|
||||||
socket.send_to(&missing, peer).unwrap();
|
|
||||||
socket.send_to(&valid, peer).unwrap();
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(server.rx.try_recv().is_err());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let wrong_key = b"wrong".to_vec();
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.hmac_key = Some(key.clone());
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
|
||||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
|
||||||
socket.send_to(&bad, peer).unwrap();
|
|
||||||
socket.send_to(&valid, peer).unwrap();
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(server.rx.try_recv().is_err());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.hmac_key = Some(key.clone());
|
|
||||||
let mut returned = params_from_config(&config).unwrap();
|
|
||||||
returned.interval_ns += 1;
|
|
||||||
let server_key = key.clone();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
|
||||||
let cleanup = &packets[1];
|
|
||||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
|
||||||
verify_packet_hmac(&key, cleanup).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
|
||||||
TOKEN
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_close_packet_includes_valid_hmac() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.hmac_key = Some(key.clone());
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server_key = key.clone();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.close().unwrap();
|
|
||||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
|
||||||
let close = &packets[1];
|
|
||||||
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
|
||||||
verify_packet_hmac(&key, close).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
|
||||||
TOKEN
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bad_hmac_reply_is_dropped() {
|
|
||||||
let key = b"secret".to_vec();
|
|
||||||
let wrong_key = b"wrong".to_vec();
|
|
||||||
let params = default_params();
|
|
||||||
let server_key = key.clone();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
|
|
||||||
socket
|
|
||||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
||||||
.unwrap();
|
|
||||||
let mut buf = [0_u8; 2048];
|
|
||||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
|
||||||
tx.send(buf[..size].to_vec()).unwrap();
|
|
||||||
let seq = u32::from_le_bytes(
|
|
||||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
|
||||||
.try_into()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let ts = TimestampFields::default();
|
|
||||||
let reply_packet = echo_reply_packet_with_flags(
|
|
||||||
TOKEN,
|
|
||||||
seq,
|
|
||||||
¶ms,
|
|
||||||
&ts,
|
|
||||||
Some(&wrong_key),
|
|
||||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
|
||||||
);
|
|
||||||
socket.send_to(&reply_packet, peer).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
hmac_key: Some(key),
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(30));
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
|
|
||||||
assert!(!client.is_peer_closed());
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_request_reply_works() {
|
|
||||||
let key = b"testkey".to_vec();
|
|
||||||
let params = default_params();
|
|
||||||
let server_key = key.clone();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
|
|
||||||
socket
|
|
||||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
||||||
.unwrap();
|
|
||||||
let mut buf = [0_u8; 2048];
|
|
||||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
|
||||||
tx.send(buf[..size].to_vec()).unwrap();
|
|
||||||
verify_packet_hmac(&server_key, &buf[..size]).unwrap();
|
|
||||||
let seq = u32::from_le_bytes(
|
|
||||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
|
||||||
.try_into()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let ts = TimestampFields {
|
|
||||||
recv_mono: Some(100),
|
|
||||||
send_mono: Some(200),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key));
|
|
||||||
socket.send_to(&reply_packet, peer).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
hmac_key: Some(key),
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
|
|
||||||
use irtt_proto::{
|
|
||||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
|
|
||||||
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
|
|
||||||
};
|
|
||||||
use std::{thread, time::SystemTime};
|
|
||||||
|
|
||||||
mod support;
|
|
||||||
use support::*;
|
|
||||||
|
|
||||||
mod close;
|
|
||||||
mod config;
|
|
||||||
mod dscp;
|
|
||||||
mod hmac;
|
|
||||||
mod negotiation;
|
|
||||||
mod no_test;
|
|
||||||
mod open;
|
|
||||||
mod probes_replies;
|
|
||||||
mod sequence;
|
|
||||||
mod ttl;
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::NegotiationRestriction;
|
|
||||||
|
|
||||||
fn assert_negotiates(
|
|
||||||
requested: &Params,
|
|
||||||
returned: &Params,
|
|
||||||
policy: NegotiationPolicy,
|
|
||||||
) -> NegotiatedParams {
|
|
||||||
negotiate_params(requested, returned.clone(), policy)
|
|
||||||
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
|
|
||||||
match negotiate_params(requested, returned.clone(), policy) {
|
|
||||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
|
||||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_negotiation_accepts_identical_params() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let negotiated = assert_negotiates(¶ms, ¶ms, NegotiationPolicy::Strict);
|
|
||||||
assert_eq!(negotiated.params, params);
|
|
||||||
assert!(negotiated.restrictions.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_negotiation_rejects_changed_params() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 46,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
requested.dscp, 184,
|
|
||||||
"codepoint 46 must become raw wire byte 184"
|
|
||||||
);
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.dscp = 0;
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
|
||||||
NegotiationRestriction::DscpChanged {
|
|
||||||
requested: 46,
|
|
||||||
negotiated: 0,
|
|
||||||
}
|
|
||||||
.message()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_accepts_server_restricted_params() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let mut requested = params_from_config(&config).unwrap();
|
|
||||||
requested.length = 128;
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.duration_ns /= 2;
|
|
||||||
returned.length = 0;
|
|
||||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
|
||||||
assert_eq!(
|
|
||||||
negotiated.restrictions,
|
|
||||||
vec![
|
|
||||||
NegotiationRestriction::DurationReduced {
|
|
||||||
requested_ns: requested.duration_ns,
|
|
||||||
negotiated_ns: returned.duration_ns,
|
|
||||||
},
|
|
||||||
NegotiationRestriction::LengthReduced {
|
|
||||||
requested: requested.length,
|
|
||||||
negotiated: returned.length,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_rejects_non_positive_returned_interval() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
|
|
||||||
for interval_ns in [0, -1] {
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.interval_ns = interval_ns;
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"interval must be positive"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_accepts_documented_duration_combinations() {
|
|
||||||
let finite_config = ClientConfig::default();
|
|
||||||
let requested_finite = params_from_config(&finite_config).unwrap();
|
|
||||||
|
|
||||||
let returned_same_finite = requested_finite.clone();
|
|
||||||
assert!(assert_negotiates(
|
|
||||||
&requested_finite,
|
|
||||||
&returned_same_finite,
|
|
||||||
NegotiationPolicy::Loose
|
|
||||||
)
|
|
||||||
.restrictions
|
|
||||||
.is_empty());
|
|
||||||
|
|
||||||
let mut returned_shorter_finite = requested_finite.clone();
|
|
||||||
returned_shorter_finite.duration_ns /= 2;
|
|
||||||
assert_eq!(
|
|
||||||
assert_negotiates(
|
|
||||||
&requested_finite,
|
|
||||||
&returned_shorter_finite,
|
|
||||||
NegotiationPolicy::Loose
|
|
||||||
)
|
|
||||||
.restrictions,
|
|
||||||
vec![NegotiationRestriction::DurationReduced {
|
|
||||||
requested_ns: requested_finite.duration_ns,
|
|
||||||
negotiated_ns: returned_shorter_finite.duration_ns,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
let continuous_config = ClientConfig {
|
|
||||||
duration: None,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested_continuous = params_from_config(&continuous_config).unwrap();
|
|
||||||
|
|
||||||
let returned_continuous = requested_continuous.clone();
|
|
||||||
assert!(assert_negotiates(
|
|
||||||
&requested_continuous,
|
|
||||||
&returned_continuous,
|
|
||||||
NegotiationPolicy::Loose
|
|
||||||
)
|
|
||||||
.restrictions
|
|
||||||
.is_empty());
|
|
||||||
|
|
||||||
let mut returned_finite = requested_continuous.clone();
|
|
||||||
returned_finite.duration_ns = 1_000_000_000;
|
|
||||||
assert_eq!(
|
|
||||||
assert_negotiates(
|
|
||||||
&requested_continuous,
|
|
||||||
&returned_finite,
|
|
||||||
NegotiationPolicy::Loose
|
|
||||||
)
|
|
||||||
.restrictions,
|
|
||||||
vec![NegotiationRestriction::DurationReduced {
|
|
||||||
requested_ns: requested_continuous.duration_ns,
|
|
||||||
negotiated_ns: returned_finite.duration_ns,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_rejects_finite_request_returned_continuous() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.duration_ns = 0;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"server returned continuous duration for finite request"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_rejects_negative_returned_length() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.length = -1;
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"length must be non-negative"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_negotiation_rejects_negative_returned_length() {
|
|
||||||
let config = ClientConfig::default();
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.length = -1;
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
|
||||||
"length must be non-negative"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 46,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
requested.dscp, 184,
|
|
||||||
"codepoint 46 must become raw wire byte 184"
|
|
||||||
);
|
|
||||||
|
|
||||||
for dscp in [-1, 256] {
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.dscp = dscp;
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"dscp must be in range 0..=255"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A raw returned value of 184 must be accepted even though it exceeds the
|
|
||||||
// 0..=63 codepoint range; it is the correctly negotiated wire value for
|
|
||||||
// codepoint 46 and is not subject to the codepoint bound at all.
|
|
||||||
let returned = requested.clone();
|
|
||||||
assert!(
|
|
||||||
assert_negotiates(&requested, &returned, NegotiationPolicy::Loose)
|
|
||||||
.restrictions
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loose_negotiation_records_dscp_disabled_by_server() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 46,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.dscp = 0;
|
|
||||||
|
|
||||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
negotiated.restrictions,
|
|
||||||
vec![NegotiationRestriction::DscpChanged {
|
|
||||||
requested: 46,
|
|
||||||
negotiated: 0,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_negotiation_rejects_dscp_disabled_by_server_as_specific_restriction() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 46,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.dscp = 0;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
|
||||||
NegotiationRestriction::DscpChanged {
|
|
||||||
requested: 46,
|
|
||||||
negotiated: 0,
|
|
||||||
}
|
|
||||||
.message()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiation_rejects_unsupported_dscp_changes() {
|
|
||||||
let config = ClientConfig {
|
|
||||||
dscp: 46,
|
|
||||||
..ClientConfig::default()
|
|
||||||
};
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut returned = requested.clone();
|
|
||||||
returned.dscp = 8;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"server returned unsupported DSCP change"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
|
||||||
"server returned unsupported DSCP change"
|
|
||||||
);
|
|
||||||
|
|
||||||
let zero_config = ClientConfig::default();
|
|
||||||
let zero_requested = params_from_config(&zero_config).unwrap();
|
|
||||||
let mut returned = zero_requested.clone();
|
|
||||||
returned.dscp = 46;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
|
|
||||||
"server returned unsupported DSCP change"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_success_validates_params() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = no_test_server(params.clone(), 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(client.next_send_deadline().is_none());
|
|
||||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
|
||||||
assert_eq!(negotiated.params, params);
|
|
||||||
assert!(client.next_send_deadline().is_none());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_rejects_non_close_open_reply() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::UnexpectedNoTestReply)
|
|
||||||
));
|
|
||||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
|
||||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
|
||||||
assert_eq!(
|
|
||||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
|
||||||
TOKEN
|
|
||||||
);
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
client.test_hooks.fail_cleanup_send.set(true);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::UnexpectedNoTestReply)
|
|
||||||
));
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_rejects_non_zero_token_with_close_reply() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = no_test_server(params, TOKEN);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_strict_negotiation_rejects_changed_params() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let mut params = params_from_config(&config).unwrap();
|
|
||||||
params.dscp = 1;
|
|
||||||
let server = no_test_server(params, 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_test_loose_negotiation_accepts_restricted_params() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
|
||||||
let requested = params_from_config(&config).unwrap();
|
|
||||||
let mut params = requested.clone();
|
|
||||||
params.duration_ns /= 2;
|
|
||||||
let server = no_test_server(params.clone(), 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
|
||||||
assert_eq!(negotiated.params, params);
|
|
||||||
assert_eq!(
|
|
||||||
negotiated.restrictions,
|
|
||||||
vec![crate::NegotiationRestriction::DurationReduced {
|
|
||||||
requested_ns: requested.duration_ns,
|
|
||||||
negotiated_ns: params.duration_ns,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn send_probe_fails_after_no_test_completed() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = no_test_server(params, 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_no_test_completed(client.open().unwrap());
|
|
||||||
assert!(matches!(
|
|
||||||
client.send_probe(),
|
|
||||||
Err(ClientError::AlreadyCompleted)
|
|
||||||
));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_fails_after_no_test_completed() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.run_mode = RunMode::NoTest;
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = no_test_server(params, 0);
|
|
||||||
config.server_addr = server.addr.to_string();
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_no_test_completed(client.open().unwrap());
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn successful_open_handshake() {
|
|
||||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = open_success_server(params.clone());
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
|
|
||||||
let negotiated = assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(negotiated.params, params);
|
|
||||||
assert!(client.schedule.is_some());
|
|
||||||
assert_eq!(client.applied_traffic_class, Some(0));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn successful_open_restores_configured_receive_timeout() {
|
|
||||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = open_success_server(params);
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client.test_hooks.last_restored_read_timeout.get(),
|
|
||||||
Some(Duration::from_millis(75))
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_fails_when_already_open() {
|
|
||||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = open_success_server(params);
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_retries_after_first_timeout() {
|
|
||||||
let server = start_fake_server(|socket, tx| {
|
|
||||||
let (first, _) = recv_request(&socket, &tx);
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let params = Params::decode(&first[4..]).unwrap();
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
let outcome = client.open().unwrap();
|
|
||||||
assert_open_started(outcome);
|
|
||||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_survives_interrupted_receive_within_same_attempt() {
|
|
||||||
let server = start_fake_server(|socket, tx| {
|
|
||||||
let (first, peer) = recv_request(&socket, &tx);
|
|
||||||
let params = Params::decode(&first[4..]).unwrap();
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(500)],
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
// Several interrupted receive attempts before the real reply arrives
|
|
||||||
// must not be treated as a timeout, must not retransmit the open
|
|
||||||
// request, and must not advance to a next configured attempt.
|
|
||||||
client.test_hooks.inject_recv_interrupted.set(5);
|
|
||||||
|
|
||||||
let outcome = client.open().unwrap();
|
|
||||||
assert_open_started(outcome);
|
|
||||||
// Exactly one request reached the server: interruptions were absorbed
|
|
||||||
// inside the single configured attempt.
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_interrupted_receive_does_not_extend_the_attempt_deadline() {
|
|
||||||
// A fake server that never replies: with a short attempt timeout, the
|
|
||||||
// real question is whether interruptions can push the effective wait
|
|
||||||
// past the configured deadline. They must not: the deadline stays
|
|
||||||
// absolute across any number of interruptions.
|
|
||||||
let server = start_fake_server(|socket, tx| {
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(200)],
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
// Each interruption carries no timing information of its own, so the
|
|
||||||
// loop must keep re-checking the real deadline rather than treating an
|
|
||||||
// interruption as "more time available" or a reason to fail outright.
|
|
||||||
client.test_hooks.inject_recv_interrupted.set(20);
|
|
||||||
|
|
||||||
let started = Instant::now();
|
|
||||||
let result = client.open();
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
assert!(matches!(result, Err(ClientError::OpenTimeout)));
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_millis(800),
|
|
||||||
"interrupted receives must not extend the attempt deadline, took {elapsed:?}"
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_timeout_after_all_timeouts() {
|
|
||||||
let server = timeout_server(Duration::from_millis(700));
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(50)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
client.test_hooks.last_restored_read_timeout.get(),
|
|
||||||
Some(Duration::from_millis(50))
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protocol_version_mismatch_fails() {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
|
||||||
let mut params = params_from_config(&config).unwrap();
|
|
||||||
params.protocol_version = 2;
|
|
||||||
let server = open_success_server(params);
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
|
||||||
));
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_rejection_fails_in_normal_mode() {
|
|
||||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
|
||||||
let params = params_from_config(&config).unwrap();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
let mut wrong_type = valid.clone();
|
|
||||||
wrong_type[3] = FLAG_REPLY;
|
|
||||||
let mut reserved_flags = valid.clone();
|
|
||||||
reserved_flags[3] |= 0x10;
|
|
||||||
let mut bad_magic = valid.clone();
|
|
||||||
bad_magic[0] ^= 0xff;
|
|
||||||
let unexpected_hmac =
|
|
||||||
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(b"unexpected"));
|
|
||||||
|
|
||||||
for packet in [
|
|
||||||
vec![0_u8],
|
|
||||||
bad_magic,
|
|
||||||
wrong_type,
|
|
||||||
reserved_flags,
|
|
||||||
unexpected_hmac,
|
|
||||||
valid,
|
|
||||||
] {
|
|
||||||
socket.send_to(&packet, peer).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(server.rx.try_recv().is_err());
|
|
||||||
assert!(client.schedule.is_some());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
thread::sleep(Duration::from_millis(150));
|
|
||||||
socket.send_to(&[0_u8], peer).unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(150));
|
|
||||||
socket.send_to(&[0_u8], peer).unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
let started = Instant::now();
|
|
||||||
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
let requests: Vec<_> = server.rx.iter().take(2).collect();
|
|
||||||
assert_eq!(requests.len(), 2);
|
|
||||||
assert!(
|
|
||||||
started.elapsed() < Duration::from_millis(700),
|
|
||||||
"ignored datagrams restarted an attempt deadline"
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_ignored_open_datagrams_eventually_time_out() {
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
for _ in 0..2 {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket.send_to(&[0_u8], peer).unwrap();
|
|
||||||
socket.send_to(&MAGIC, peer).unwrap();
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(300));
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(50)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
|
||||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
|
||||||
assert!(server.rx.try_recv().is_err());
|
|
||||||
assert_eq!(
|
|
||||||
client.test_hooks.last_restored_read_timeout.get(),
|
|
||||||
Some(Duration::from_millis(50))
|
|
||||||
);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trusted_zero_token_normal_reply_is_terminal() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
|
|
||||||
// deliberately non-compliant reply is built by encoding a normal
|
|
||||||
// reply with a placeholder token and then zeroing the token field.
|
|
||||||
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
|
||||||
));
|
|
||||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_deadline_overflow_occurs_before_send() {
|
|
||||||
let server = timeout_server(Duration::from_millis(250));
|
|
||||||
let config = ClientConfig {
|
|
||||||
open_timeouts: vec![Duration::MAX],
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
|
|
||||||
assert!(server.rx.try_recv().is_err());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schedule_duration_overflow_cannot_partially_open() {
|
|
||||||
let server = start_fake_server(|_socket, _tx| {});
|
|
||||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
let reply = irtt_proto::OpenReply {
|
|
||||||
flags: FLAG_OPEN | FLAG_REPLY,
|
|
||||||
token: TOKEN,
|
|
||||||
params: params_from_config(client.runtime.config()).unwrap(),
|
|
||||||
};
|
|
||||||
let mut latest = Instant::now();
|
|
||||||
let mut step = Duration::MAX;
|
|
||||||
while !step.is_zero() {
|
|
||||||
if let Some(candidate) = latest.checked_add(step) {
|
|
||||||
latest = candidate;
|
|
||||||
}
|
|
||||||
step /= 2;
|
|
||||||
}
|
|
||||||
let opened_at = ClientTimestamp {
|
|
||||||
mono: latest,
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let machine = client
|
|
||||||
.runtime
|
|
||||||
.prepare_open_acceptance(reply, opened_at)
|
|
||||||
.unwrap();
|
|
||||||
let previous_recv_len = client.recv_buffer.len();
|
|
||||||
|
|
||||||
let failure = client.prepare_client_open(machine, opened_at).unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
failure.primary,
|
|
||||||
ClientError::NegotiationRejected { .. }
|
|
||||||
));
|
|
||||||
assert!(failure.machine.cleanup_close_packet().is_some());
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
assert_eq!(client.recv_buffer.len(), previous_recv_len);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_token_negotiation_failure_sends_cleanup_close() {
|
|
||||||
let mut returned = default_params();
|
|
||||||
returned.interval_ns += 1;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
|
||||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
|
||||||
assert_eq!(
|
|
||||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
|
||||||
TOKEN
|
|
||||||
);
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cleanup_send_failure_does_not_replace_primary_open_error() {
|
|
||||||
let mut returned = default_params();
|
|
||||||
returned.interval_ns += 1;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
client.test_hooks.fail_cleanup_send.set(true);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::NegotiationRejected { .. })
|
|
||||||
));
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn receive_buffer_reservation_failure_precedes_dscp_application() {
|
|
||||||
let mut returned = default_params();
|
|
||||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF), matching
|
|
||||||
// what the client below requests so negotiation succeeds unchanged.
|
|
||||||
returned.dscp = 184;
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.dscp = 46;
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
client
|
|
||||||
.test_hooks
|
|
||||||
.recv_buffer_len_override
|
|
||||||
.set(Some(usize::MAX));
|
|
||||||
client.test_hooks.fail_open_dscp.set(true);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::AllocationFailed {
|
|
||||||
operation: "negotiated receive buffer",
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert!(client.test_hooks.fail_open_dscp.get());
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dscp_application_failure_leaves_machine_connected() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
client.test_hooks.fail_open_dscp.set(true);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::SocketOption {
|
|
||||||
operation: "set negotiated DSCP",
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert!(client.test_hooks.prepared_active_session_before_dscp.get());
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_timeout_restoration_failure_leaves_machine_connected() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.open(),
|
|
||||||
Err(ClientError::ReadTimeoutRestore { .. })
|
|
||||||
));
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dscp_rollback_failure_keeps_timeout_restoration_primary() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
socket
|
|
||||||
.send_to(
|
|
||||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let _ = recv_request(&socket, &tx);
|
|
||||||
});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
|
||||||
client.test_hooks.fail_dscp_restore.set(true);
|
|
||||||
|
|
||||||
let error = client.open().unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, ClientError::ReadTimeoutRestore { .. }));
|
|
||||||
assert!(client.runtime.prepare_open_request().is_ok());
|
|
||||||
assert!(client.schedule.is_none());
|
|
||||||
assert_eq!(client.applied_traffic_class, None);
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
/// An interrupted receive (`io::ErrorKind::Interrupted`, e.g. `EINTR`) must be
|
|
||||||
/// invisible to the caller: retried transparently, with the eventual reply
|
|
||||||
/// classified exactly as if no interruption had occurred.
|
|
||||||
#[test]
|
|
||||||
fn interrupted_receive_is_retried_and_the_reply_is_still_classified() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
|
|
||||||
let mut buf = [0_u8; 2048];
|
|
||||||
let (size, _) = socket.recv_from(&mut buf).unwrap();
|
|
||||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
|
||||||
let _ = size;
|
|
||||||
let ts = TimestampFields::default();
|
|
||||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None);
|
|
||||||
socket.send_to(&reply_packet, peer).unwrap();
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_secs(2)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
|
|
||||||
client.test_hooks.inject_recv_interrupted.set(10);
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(
|
|
||||||
matches!(&events[0], ClientEvent::EchoReply { seq: 0, .. }),
|
|
||||||
"expected a normal EchoReply despite interrupted receive attempts, got {:?}",
|
|
||||||
events[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One logical receive keeps one timeout budget: repeated interruptions must
|
|
||||||
/// not extend a `recv_once` call past its configured receive timeout.
|
|
||||||
#[test]
|
|
||||||
fn interrupted_receive_does_not_extend_the_configured_timeout() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(100)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
// No reply is ever coming; only interrupted attempts occur before the
|
|
||||||
// real socket takes over and legitimately times out.
|
|
||||||
client.test_hooks.inject_recv_interrupted.set(20);
|
|
||||||
let started = Instant::now();
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
assert!(events.is_empty());
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_millis(500),
|
|
||||||
"interrupted receives must not extend the configured receive timeout, took {elapsed:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
fn probe_timestamps(permission_at: Instant, sent_at: ClientTimestamp) -> ProbeSendTimestamps {
|
|
||||||
ProbeSendTimestamps {
|
|
||||||
permission_at,
|
|
||||||
send_anchor: sent_at,
|
|
||||||
sent_at,
|
|
||||||
send_call_start: sent_at.mono,
|
|
||||||
send_finished_at: sent_at.mono,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn send_probe_fails_before_open() {
|
|
||||||
let server = start_fake_server(|_socket, _tx| {});
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn send_probe_sends_valid_echo_request() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params.clone());
|
|
||||||
let config = ClientConfig {
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert!(client.next_send_deadline().is_none());
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
let events = client.send_probe().unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
ClientEvent::EchoSent {
|
|
||||||
seq, remote, bytes, ..
|
|
||||||
} => {
|
|
||||||
assert_eq!(*seq, 0);
|
|
||||||
assert_eq!(*remote, server.addr);
|
|
||||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
|
||||||
}
|
|
||||||
other => panic!("expected EchoSent, got {other:?}"),
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(30));
|
|
||||||
let packets: Vec<_> = server.rx.try_iter().collect();
|
|
||||||
let echo_reqs: Vec<_> = packets
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
|
|
||||||
.collect();
|
|
||||||
let echo_req = echo_reqs.first().unwrap();
|
|
||||||
assert_eq!(&echo_req[..3], &MAGIC);
|
|
||||||
assert_eq!(echo_req[3], 0x00);
|
|
||||||
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
|
|
||||||
assert_eq!(req_token, TOKEN);
|
|
||||||
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
|
|
||||||
assert_eq!(seq, 0);
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocking_short_probe_commits_before_presentation_length_error() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params.clone());
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let expected = echo_packet_len(false, ¶ms);
|
|
||||||
let initial_deadline = client.next_send_deadline().unwrap();
|
|
||||||
client.probe_reported_len = Some(expected - 1);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.send_probe(),
|
|
||||||
Err(ClientError::DatagramLengthMismatch {
|
|
||||||
expected: error_expected,
|
|
||||||
actual,
|
|
||||||
}) if error_expected == expected && actual + 1 == expected
|
|
||||||
));
|
|
||||||
assert_eq!(client.runtime.packets_sent(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
client.next_send_deadline(),
|
|
||||||
Some(initial_deadline + client.runtime.config().interval)
|
|
||||||
);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocking_failed_probe_send_preserves_machine_and_schedule() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params);
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let initial_deadline = client.next_send_deadline().unwrap();
|
|
||||||
client.probe_send_error = true;
|
|
||||||
|
|
||||||
assert!(matches!(client.send_probe(), Err(ClientError::Socket(_))));
|
|
||||||
assert_eq!(client.runtime.packets_sent(), 0);
|
|
||||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.send_probe().unwrap().as_slice(),
|
|
||||||
[ClientEvent::EchoSent { seq: 0, .. }]
|
|
||||||
));
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocking_timeout_finalization_overflow_does_not_send_or_commit() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
probe_timeout: Duration::MAX,
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let initial_deadline = client.next_send_deadline().unwrap();
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
mono: Instant::now(),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
client.send_probe_at(probe_timestamps(initial_deadline, sent_at)),
|
|
||||||
Err(ClientError::DurationOverflow)
|
|
||||||
));
|
|
||||||
assert_eq!(client.runtime.packets_sent(), 0);
|
|
||||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
|
||||||
thread::sleep(Duration::from_millis(30));
|
|
||||||
assert!(server
|
|
||||||
.rx
|
|
||||||
.try_iter()
|
|
||||||
.all(|packet| packet.len() >= 4 && packet[3] & FLAG_OPEN != 0));
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocking_probe_separates_permission_committed_send_and_send_call_timing() {
|
|
||||||
let duration = Duration::from_secs(1);
|
|
||||||
let interval = Duration::from_millis(100);
|
|
||||||
let params = Params {
|
|
||||||
duration_ns: 1_000_000_000,
|
|
||||||
interval_ns: 100_000_000,
|
|
||||||
..default_params()
|
|
||||||
};
|
|
||||||
let server = silent_open_server(params.clone());
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: Some(duration),
|
|
||||||
interval,
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let opened_mono = Instant::now()
|
|
||||||
.checked_sub(Duration::from_secs(2))
|
|
||||||
.expect("test host monotonic clock has at least two seconds of history");
|
|
||||||
client.schedule = Some(
|
|
||||||
ProbeSchedule::new(
|
|
||||||
opened_mono,
|
|
||||||
&NegotiatedParams {
|
|
||||||
params,
|
|
||||||
restrictions: Vec::new(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let permission_at = opened_mono + Duration::from_millis(500);
|
|
||||||
// Distinct pre-send anchor and post-send `sent_at`: the anchor drives
|
|
||||||
// the operational timeout deadline, while `sent_at` (captured after the
|
|
||||||
// send call) is the public measurement/timer_error endpoint. They are
|
|
||||||
// deliberately decoupled by this PR.
|
|
||||||
let send_anchor = ClientTimestamp {
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
mono: opened_mono + Duration::from_millis(525),
|
|
||||||
};
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
mono: opened_mono + Duration::from_millis(534),
|
|
||||||
};
|
|
||||||
let timestamps = ProbeSendTimestamps {
|
|
||||||
permission_at,
|
|
||||||
send_anchor,
|
|
||||||
sent_at,
|
|
||||||
send_call_start: opened_mono + Duration::from_millis(526),
|
|
||||||
send_finished_at: opened_mono + Duration::from_millis(533),
|
|
||||||
};
|
|
||||||
|
|
||||||
let events = client.send_probe_at(timestamps).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
events.as_slice(),
|
|
||||||
[ClientEvent::EchoSent {
|
|
||||||
scheduled_at,
|
|
||||||
sent_at: event_sent_at,
|
|
||||||
send_call,
|
|
||||||
timer_error,
|
|
||||||
..
|
|
||||||
}] if *scheduled_at == opened_mono
|
|
||||||
&& *event_sent_at == sent_at
|
|
||||||
&& *send_call == Duration::from_millis(7)
|
|
||||||
&& *timer_error == Duration::from_millis(534)
|
|
||||||
));
|
|
||||||
assert_eq!(client.next_send_deadline(), Some(opened_mono + interval));
|
|
||||||
assert_eq!(client.runtime.packets_sent(), 1);
|
|
||||||
// The timeout deadline is anchored to the pre-send `send_anchor`, not
|
|
||||||
// to the post-send `sent_at` captured 9ms later.
|
|
||||||
let timeout_at = send_anchor.mono + client.probe_timeout();
|
|
||||||
assert_ne!(timeout_at, sent_at.mono + client.probe_timeout());
|
|
||||||
assert!(client
|
|
||||||
.poll_timeouts_at(timeout_at - Duration::from_nanos(1))
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
assert!(matches!(
|
|
||||||
client.poll_timeouts_at(timeout_at).unwrap().as_slice(),
|
|
||||||
[ClientEvent::EchoLoss {
|
|
||||||
sent_at: loss_sent_at,
|
|
||||||
..
|
|
||||||
}] if *loss_sent_at == sent_at
|
|
||||||
));
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocking_send_call_excludes_wrapped_history_cleanup() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = silent_open_server(params);
|
|
||||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let scheduled_at = client.next_send_deadline().unwrap();
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
mono: scheduled_at + Duration::from_millis(5),
|
|
||||||
};
|
|
||||||
client.runtime.seed_wrapped_probe_history_for_test(sent_at);
|
|
||||||
let timestamps = ProbeSendTimestamps {
|
|
||||||
permission_at: scheduled_at,
|
|
||||||
send_anchor: sent_at,
|
|
||||||
sent_at,
|
|
||||||
send_call_start: scheduled_at + Duration::from_millis(6),
|
|
||||||
send_finished_at: scheduled_at + Duration::from_millis(13),
|
|
||||||
};
|
|
||||||
|
|
||||||
let events = client.send_probe_at(timestamps).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
events.as_slice(),
|
|
||||||
[ClientEvent::EchoSent {
|
|
||||||
seq: 0,
|
|
||||||
send_call,
|
|
||||||
..
|
|
||||||
}] if *send_call == Duration::from_millis(7)
|
|
||||||
));
|
|
||||||
assert!(!client.runtime.has_timed_out_metadata());
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn send_probe_respects_finite_duration_exclusive_end() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 1_000_000_000,
|
|
||||||
interval_ns: 500_000_000,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let server = silent_open_server(params.clone());
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: Some(Duration::from_secs(1)),
|
|
||||||
interval: Duration::from_millis(500),
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let start = client.next_send_deadline().unwrap();
|
|
||||||
let interval = Duration::from_millis(500);
|
|
||||||
|
|
||||||
let now0 = ClientTimestamp {
|
|
||||||
mono: start,
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
assert!(client
|
|
||||||
.send_probe_at(probe_timestamps(now0.mono, now0))
|
|
||||||
.is_ok());
|
|
||||||
|
|
||||||
let now1 = ClientTimestamp {
|
|
||||||
mono: start + interval,
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
assert!(client
|
|
||||||
.send_probe_at(probe_timestamps(now1.mono, now1))
|
|
||||||
.is_ok());
|
|
||||||
|
|
||||||
let now2 = ClientTimestamp {
|
|
||||||
mono: start + Duration::from_secs(1),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let events = client
|
|
||||||
.send_probe_at(probe_timestamps(now2.mono, now2))
|
|
||||||
.unwrap();
|
|
||||||
assert!(events.is_empty());
|
|
||||||
assert!(client.next_send_deadline().is_none());
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn managed_probe_skips_missed_schedule_slots() {
|
|
||||||
let interval = Duration::from_millis(10);
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 0,
|
|
||||||
interval_ns: 10_000_000,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let server = echo_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: None,
|
|
||||||
interval,
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let first_deadline = client.next_send_deadline().unwrap();
|
|
||||||
let permission_at = first_deadline + Duration::from_millis(45);
|
|
||||||
let delayed_send = ClientTimestamp {
|
|
||||||
mono: first_deadline + Duration::from_millis(48),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let timestamps = ProbeSendTimestamps {
|
|
||||||
permission_at,
|
|
||||||
send_anchor: delayed_send,
|
|
||||||
sent_at: delayed_send,
|
|
||||||
send_call_start: first_deadline + Duration::from_millis(49),
|
|
||||||
send_finished_at: first_deadline + Duration::from_millis(55),
|
|
||||||
};
|
|
||||||
let events = client
|
|
||||||
.send_managed_probe_at(first_deadline, timestamps)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(matches!(
|
|
||||||
events[0],
|
|
||||||
ClientEvent::EchoSent {
|
|
||||||
seq: 0,
|
|
||||||
scheduled_at,
|
|
||||||
sent_at,
|
|
||||||
send_call,
|
|
||||||
timer_error,
|
|
||||||
..
|
|
||||||
} if scheduled_at == first_deadline + Duration::from_millis(40)
|
|
||||||
&& sent_at == delayed_send
|
|
||||||
&& send_call == Duration::from_millis(6)
|
|
||||||
&& timer_error == Duration::from_millis(8)
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
client.next_send_deadline(),
|
|
||||||
Some(first_deadline + Duration::from_millis(50))
|
|
||||||
);
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn managed_probe_skip_preserves_finite_run_end() {
|
|
||||||
let interval = Duration::from_millis(10);
|
|
||||||
let duration = Duration::from_millis(50);
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 50_000_000,
|
|
||||||
interval_ns: 10_000_000,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let server = echo_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
duration: Some(duration),
|
|
||||||
interval,
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
let first_deadline = client.next_send_deadline().unwrap();
|
|
||||||
let delayed_send = ClientTimestamp {
|
|
||||||
mono: first_deadline + Duration::from_millis(45),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let first = client
|
|
||||||
.send_managed_probe_at(
|
|
||||||
first_deadline,
|
|
||||||
probe_timestamps(delayed_send.mono, delayed_send),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second_sent_at = ClientTimestamp {
|
|
||||||
mono: first_deadline + Duration::from_millis(46),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let second = client
|
|
||||||
.send_managed_probe_at(
|
|
||||||
first_deadline + duration,
|
|
||||||
probe_timestamps(second_sent_at.mono, second_sent_at),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(first.len(), 1);
|
|
||||||
assert!(second.is_empty());
|
|
||||||
assert!(client.next_send_deadline().is_none());
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn receive_wait_is_bounded_by_next_ten_millisecond_deadline() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let deadline = now + Duration::from_millis(10);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
bounded_receive_timeout(
|
|
||||||
Some(deadline),
|
|
||||||
Some(Duration::from_millis(20)),
|
|
||||||
Duration::from_millis(20),
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
Some(Duration::from_millis(10))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
bounded_receive_timeout(
|
|
||||||
Some(now),
|
|
||||||
Some(Duration::from_millis(20)),
|
|
||||||
Duration::from_millis(20),
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_rejects_invalid_probe_limits() {
|
|
||||||
for config in [
|
|
||||||
ClientConfig {
|
|
||||||
max_pending_probes: 0,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
ClientConfig {
|
|
||||||
probe_timeout: Duration::ZERO,
|
|
||||||
..ClientConfig::default()
|
|
||||||
},
|
|
||||||
] {
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recv_once_returns_empty_on_timeout() {
|
|
||||||
let params = default_params();
|
|
||||||
let server = open_success_server(params);
|
|
||||||
let config = ClientConfig {
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(50)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
assert!(events.is_empty());
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recv_once_decodes_echo_reply_and_emits_event() {
|
|
||||||
let params = default_params();
|
|
||||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
|
||||||
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
ClientEvent::EchoReply {
|
|
||||||
seq,
|
|
||||||
rtt,
|
|
||||||
received_stats,
|
|
||||||
server_timing,
|
|
||||||
bytes,
|
|
||||||
packet_meta,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
assert_eq!(*seq, 0);
|
|
||||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
|
||||||
assert!(rtt.raw > Duration::ZERO);
|
|
||||||
assert_eq!(
|
|
||||||
rtt.effective,
|
|
||||||
rtt.adjusted
|
|
||||||
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
|
|
||||||
);
|
|
||||||
assert!(received_stats.is_some());
|
|
||||||
let stats = received_stats.as_ref().unwrap();
|
|
||||||
assert_eq!(stats.count, Some(42));
|
|
||||||
assert_eq!(stats.window, Some(0x07));
|
|
||||||
assert!(server_timing.is_some());
|
|
||||||
let st = server_timing.as_ref().unwrap();
|
|
||||||
assert!(st.processing.is_some());
|
|
||||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
|
||||||
{
|
|
||||||
assert_eq!(packet_meta.traffic_class, None);
|
|
||||||
assert_eq!(packet_meta.dscp, None);
|
|
||||||
assert_eq!(packet_meta.ecn, None);
|
|
||||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
|
||||||
}
|
|
||||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
|
||||||
{
|
|
||||||
assert_eq!(
|
|
||||||
packet_meta.dscp,
|
|
||||||
packet_meta.traffic_class.map(|tc| tc >> 2)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
packet_meta.ecn,
|
|
||||||
packet_meta.traffic_class.map(|tc| tc & 0b11)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => panic!("expected EchoReply, got {other:?}"),
|
|
||||||
}
|
|
||||||
client.close().unwrap();
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
|
|
||||||
let params = default_params();
|
|
||||||
let server_params = params.clone();
|
|
||||||
let server = start_fake_server(move |socket, tx| {
|
|
||||||
let (_, peer) = recv_request(&socket, &tx);
|
|
||||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
|
|
||||||
socket.send_to(&reply, peer).unwrap();
|
|
||||||
|
|
||||||
let mut seqs = Vec::new();
|
|
||||||
for _ in 0..2 {
|
|
||||||
let (packet, _) = recv_request(&socket, &tx);
|
|
||||||
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut longer = echo_reply_packet(
|
|
||||||
TOKEN,
|
|
||||||
seqs[0],
|
|
||||||
&server_params,
|
|
||||||
&TimestampFields::default(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
longer.extend_from_slice(b"trailing bytes");
|
|
||||||
socket.send_to(&longer, peer).unwrap();
|
|
||||||
|
|
||||||
let shorter = echo_reply_packet(
|
|
||||||
TOKEN,
|
|
||||||
seqs[1],
|
|
||||||
&server_params,
|
|
||||||
&TimestampFields::default(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
socket.send_to(&shorter, peer).unwrap();
|
|
||||||
});
|
|
||||||
let config = ClientConfig {
|
|
||||||
socket_config: crate::SocketConfig {
|
|
||||||
recv_timeout: Some(Duration::from_millis(200)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..default_test_config(server.addr)
|
|
||||||
};
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
assert_open_started(client.open().unwrap());
|
|
||||||
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
client.send_probe().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
|
|
||||||
let _ = client.recv_once().unwrap();
|
|
||||||
let events = client.recv_once().unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
ClientEvent::EchoReply { seq, bytes, .. } => {
|
|
||||||
assert_eq!(*seq, 1);
|
|
||||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
|
||||||
}
|
|
||||||
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
|
|
||||||
}
|
|
||||||
server.join();
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::metadata::ReceiveMeta;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_processing_greater_than_raw_does_not_underflow() {
|
|
||||||
let base = Instant::now();
|
|
||||||
let rtt = compute_rtt(
|
|
||||||
&ClientTimestamp {
|
|
||||||
mono: base,
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
},
|
|
||||||
&ClientTimestamp {
|
|
||||||
mono: base + Duration::from_nanos(1),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
},
|
|
||||||
&TimestampFields {
|
|
||||||
recv_mono: Some(0),
|
|
||||||
send_mono: Some(1_000_000_000),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
|
|
||||||
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
|
|
||||||
let base = Instant::now();
|
|
||||||
let rtt = compute_rtt(
|
|
||||||
&ClientTimestamp {
|
|
||||||
mono: base,
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
},
|
|
||||||
&ClientTimestamp {
|
|
||||||
mono: base + Duration::from_nanos(42),
|
|
||||||
wall: SystemTime::now(),
|
|
||||||
},
|
|
||||||
&TimestampFields::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(rtt.adjusted, None);
|
|
||||||
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
|
|
||||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
|
||||||
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
|
|
||||||
let overflow = max + Duration::from_nanos(1);
|
|
||||||
|
|
||||||
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
|
|
||||||
assert_eq!(unix_epoch_ns_i64(overflow), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
|
|
||||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
|
||||||
let overflow_wall =
|
|
||||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
mono: Instant::now(),
|
|
||||||
wall: overflow_wall,
|
|
||||||
};
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
mono: sent_at.mono + Duration::from_millis(40),
|
|
||||||
wall: overflow_wall + Duration::from_millis(40),
|
|
||||||
};
|
|
||||||
let ts = TimestampFields {
|
|
||||||
recv_wall: Some(i64::MAX),
|
|
||||||
send_wall: Some(i64::MAX),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
compute_one_way(
|
|
||||||
&sent_at,
|
|
||||||
sent_at.wall,
|
|
||||||
&received_at,
|
|
||||||
None,
|
|
||||||
&ReceiveMeta::default(),
|
|
||||||
&ts
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_one_way_returns_available_direction_samples() {
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
mono: Instant::now(),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
|
||||||
};
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
mono: sent_at.mono + Duration::from_millis(40),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
|
||||||
};
|
|
||||||
let ts = TimestampFields {
|
|
||||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
|
||||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let sample = compute_one_way(
|
|
||||||
&sent_at,
|
|
||||||
sent_at.wall,
|
|
||||||
&received_at,
|
|
||||||
None,
|
|
||||||
&ReceiveMeta::default(),
|
|
||||||
&ts,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
sample.client_to_server,
|
|
||||||
Some(SignedDuration::from_nanos(15_000_000))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sample.server_to_client,
|
|
||||||
Some(SignedDuration::from_nanos(15_000_000))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_one_way_preserves_negative_client_to_server_delay() {
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
mono: Instant::now(),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
|
||||||
};
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
mono: sent_at.mono + Duration::from_millis(40),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
|
||||||
};
|
|
||||||
let ts = TimestampFields {
|
|
||||||
recv_wall: Some(10_000_000_000 - 5_000_000),
|
|
||||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let sample = compute_one_way(
|
|
||||||
&sent_at,
|
|
||||||
sent_at.wall,
|
|
||||||
&received_at,
|
|
||||||
None,
|
|
||||||
&ReceiveMeta::default(),
|
|
||||||
&ts,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
sample.client_to_server,
|
|
||||||
Some(SignedDuration::from_nanos(-5_000_000))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sample.server_to_client,
|
|
||||||
Some(SignedDuration::from_nanos(15_000_000))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_one_way_preserves_negative_server_to_client_delay() {
|
|
||||||
let sent_at = ClientTimestamp {
|
|
||||||
mono: Instant::now(),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
|
||||||
};
|
|
||||||
let received_at = ClientTimestamp {
|
|
||||||
mono: sent_at.mono + Duration::from_millis(40),
|
|
||||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
|
||||||
};
|
|
||||||
let ts = TimestampFields {
|
|
||||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
|
||||||
send_wall: Some(10_000_000_000 + 45_000_000),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let sample = compute_one_way(
|
|
||||||
&sent_at,
|
|
||||||
sent_at.wall,
|
|
||||||
&received_at,
|
|
||||||
None,
|
|
||||||
&ReceiveMeta::default(),
|
|
||||||
&ts,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
sample.client_to_server,
|
|
||||||
Some(SignedDuration::from_nanos(15_000_000))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sample.server_to_client,
|
|
||||||
Some(SignedDuration::from_nanos(-5_000_000))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
|
||||||
assert!(sequence_is_after(11, 10));
|
|
||||||
assert!(sequence_is_before(9, 10));
|
|
||||||
assert!(!sequence_is_after(10, 10));
|
|
||||||
assert!(!sequence_is_before(10, 10));
|
|
||||||
|
|
||||||
assert!(sequence_is_after(0, u32::MAX - 1));
|
|
||||||
assert!(!sequence_is_before(0, u32::MAX - 1));
|
|
||||||
assert!(sequence_is_after(1, u32::MAX));
|
|
||||||
assert!(!sequence_is_before(1, u32::MAX));
|
|
||||||
|
|
||||||
assert!(sequence_is_before(u32::MAX, 1));
|
|
||||||
assert!(!sequence_is_after(u32::MAX, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highest_received_seq_updates_across_wrap() {
|
|
||||||
let mut highest = Some(u32::MAX);
|
|
||||||
|
|
||||||
update_highest_received(&mut highest, 0);
|
|
||||||
assert_eq!(highest, Some(0));
|
|
||||||
update_highest_received(&mut highest, u32::MAX);
|
|
||||||
assert_eq!(highest, Some(0));
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use crate::socket_options::socket_ttl;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_accepts_valid_ttl_config_values() {
|
|
||||||
for ttl in [None, Some(1), Some(255)] {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
|
||||||
config.socket_config.ttl = ttl;
|
|
||||||
Client::connect(config).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: PROTOCOL_VERSION,
|
|
||||||
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()
|
|
||||||
};
|
|
||||||
let server = open_success_server(params.clone());
|
|
||||||
let mut config = default_test_config(server.addr);
|
|
||||||
config.socket_config.ttl = Some(64);
|
|
||||||
let mut client = Client::connect(config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
|
||||||
|
|
||||||
client.open().unwrap();
|
|
||||||
server.join();
|
|
||||||
|
|
||||||
client.close().unwrap();
|
|
||||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_rejects_invalid_ttl_values() {
|
|
||||||
for ttl in [0, 256] {
|
|
||||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
|
||||||
config.socket_config.ttl = Some(ttl);
|
|
||||||
assert!(matches!(
|
|
||||||
Client::connect(config),
|
|
||||||
Err(ClientError::InvalidConfig { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::{net::SocketAddr, time::Duration};
|
use std::{net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
|
use crate::{Authentication, ClientError};
|
||||||
|
|
||||||
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
||||||
/// bytes.
|
/// bytes.
|
||||||
///
|
///
|
||||||
/// This is the maximum encoded server-fill string accepted by this client and
|
/// This is the maximum encoded server-fill string accepted by this client and
|
||||||
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
|
/// protocol decoder. [`SessionRequest::server_fill`] enforces the same bound
|
||||||
/// before opening a session.
|
/// before opening a session.
|
||||||
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
||||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||||
@@ -26,7 +28,7 @@ pub const MAX_TTL: u32 = 255;
|
|||||||
/// Largest UDP payload length accepted by client configuration, in bytes.
|
/// Largest UDP payload length accepted by client configuration, in bytes.
|
||||||
///
|
///
|
||||||
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
|
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
|
||||||
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
|
/// [`SessionRequest::length`] before protocol packets are encoded or sent.
|
||||||
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
||||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
@@ -45,27 +47,111 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
|||||||
/// This type describes both the protocol parameters sent in the IRTT open
|
/// This type describes both the protocol parameters sent in the IRTT open
|
||||||
/// request and the local client behavior used to drive the UDP socket. Values
|
/// request and the local client behavior used to drive the UDP socket. Values
|
||||||
/// that are negotiated by the server are available after opening the session
|
/// that are negotiated by the server are available after opening the session
|
||||||
/// through [`NegotiatedParams`](crate::NegotiatedParams).
|
/// through [`NegotiationResult`](crate::NegotiationResult).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// Configure a finite run with larger probes and accept documented server
|
||||||
|
/// restrictions. Use `duration: None` for continuous mode, or
|
||||||
|
/// [`RunMode::NoTest`] for negotiation without probes.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use std::time::Duration;
|
||||||
|
///
|
||||||
|
/// use irtt_client::{ClientConfig, NegotiationPolicy, OpenPolicy, SessionRequest};
|
||||||
|
///
|
||||||
|
/// let config = ClientConfig {
|
||||||
|
/// request: SessionRequest {
|
||||||
|
/// duration: Some(Duration::from_secs(10)),
|
||||||
|
/// interval: Duration::from_millis(250),
|
||||||
|
/// length: 1200,
|
||||||
|
/// ..SessionRequest::default()
|
||||||
|
/// },
|
||||||
|
/// open: OpenPolicy {
|
||||||
|
/// negotiation: NegotiationPolicy::Loose,
|
||||||
|
/// ..OpenPolicy::default()
|
||||||
|
/// },
|
||||||
|
/// ..ClientConfig::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// assert_eq!(config.request.duration, Some(Duration::from_secs(10)));
|
||||||
|
/// assert_eq!(config.request.interval, Duration::from_millis(250));
|
||||||
|
/// assert_eq!(config.request.length, 1200);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClientConfig {
|
pub struct ClientConfig {
|
||||||
/// Remote server name or address.
|
/// Remote address families allowed during resolution and socket creation.
|
||||||
|
pub address_family: AddressFamily,
|
||||||
|
/// Local UDP socket properties.
|
||||||
|
pub socket: SocketConfig,
|
||||||
|
/// Protocol and session values requested from the server.
|
||||||
|
pub request: SessionRequest,
|
||||||
|
/// Open retry timeouts and negotiation policy.
|
||||||
|
pub open: OpenPolicy,
|
||||||
|
/// Concrete authentication for open, echo, and close packets.
|
||||||
///
|
///
|
||||||
/// If no port is present, the default IRTT port 2112 is used. IPv6
|
/// HMAC peers must use the same key. An empty HMAC key remains authenticated.
|
||||||
/// literals may be supplied either bracketed or unbracketed when the
|
pub auth: Authentication,
|
||||||
/// default port should be used.
|
/// Time after sending a probe before the client reports it as lost.
|
||||||
pub server_addr: String,
|
///
|
||||||
|
/// This timeout is local client behavior; it is not negotiated with the
|
||||||
|
/// server. It must be greater than zero.
|
||||||
|
pub probe_timeout: Duration,
|
||||||
|
/// Maximum number of probes tracked as pending/timed-out/completed.
|
||||||
|
///
|
||||||
|
/// This bounds memory used for reply classification and must be greater
|
||||||
|
/// than zero. A very small value can reject sends when replies are still
|
||||||
|
/// outstanding.
|
||||||
|
pub max_pending_probes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
address_family: AddressFamily::default(),
|
||||||
|
socket: SocketConfig::default(),
|
||||||
|
request: SessionRequest::default(),
|
||||||
|
open: OpenPolicy::default(),
|
||||||
|
auth: Authentication::Unauthenticated,
|
||||||
|
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
||||||
|
max_pending_probes: DEFAULT_MAX_PENDING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allowed remote address families. IPv6-only selection also sets IPV6_V6ONLY.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum AddressFamily {
|
||||||
|
/// Accept IPv4 and IPv6 addresses.
|
||||||
|
#[default]
|
||||||
|
Any,
|
||||||
|
/// Accept only IPv4 addresses.
|
||||||
|
Ipv4,
|
||||||
|
/// Accept only IPv6 addresses and use IPv6-only sockets.
|
||||||
|
Ipv6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requested IRTT protocol and session semantics, reusable across endpoints.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SessionRequest {
|
||||||
/// Requested run duration.
|
/// Requested run duration.
|
||||||
///
|
///
|
||||||
/// `Some(duration)` requests a finite test and must be greater than zero.
|
/// `Some(duration)` requests a finite test and must be greater than zero.
|
||||||
/// `None` requests continuous mode and is encoded on the wire as a zero
|
/// `None` requests continuous mode and is encoded on the wire as a zero
|
||||||
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
|
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
|
||||||
/// without sending probes.
|
/// without sending probes.
|
||||||
|
///
|
||||||
|
/// The managed driver stops sending after the negotiated duration.
|
||||||
|
/// Low-level adapters leave run duration to the caller.
|
||||||
pub duration: Option<Duration>,
|
pub duration: Option<Duration>,
|
||||||
/// Requested spacing between probe sends.
|
/// Requested spacing between probe sends.
|
||||||
///
|
///
|
||||||
/// The interval is encoded as nanoseconds in the open request and must be
|
/// The interval is encoded as nanoseconds in the open request and must be
|
||||||
/// greater than zero. The server may return a different interval depending
|
/// greater than zero. The server may return a different interval depending
|
||||||
/// on the negotiated policy and server restrictions.
|
/// on the negotiated policy and server restrictions.
|
||||||
|
///
|
||||||
|
/// The managed driver uses the negotiated interval for cadence.
|
||||||
|
/// Low-level adapters send whenever called.
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
/// Requested echo packet payload length, in bytes.
|
/// Requested echo packet payload length, in bytes.
|
||||||
///
|
///
|
||||||
@@ -87,71 +173,20 @@ pub struct ClientConfig {
|
|||||||
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
||||||
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
||||||
pub dscp: u8,
|
pub dscp: u8,
|
||||||
/// Optional HMAC key used to authenticate IRTT packets.
|
|
||||||
///
|
|
||||||
/// When present, open, echo, and close packets are encoded and decoded with
|
|
||||||
/// HMAC authentication. The peer must be configured with the same key.
|
|
||||||
pub hmac_key: Option<Vec<u8>>,
|
|
||||||
/// Optional server payload fill request.
|
/// Optional server payload fill request.
|
||||||
///
|
///
|
||||||
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
||||||
/// a non-empty server fill mode/value and must not exceed
|
/// a non-empty server fill mode/value and must not exceed
|
||||||
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
|
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
|
||||||
pub server_fill: Option<String>,
|
pub server_fill: Option<String>,
|
||||||
/// Per-attempt receive timeouts used while opening the session.
|
|
||||||
///
|
|
||||||
/// The client sends an open request for each entry until a valid open reply
|
|
||||||
/// is received. The list must not be empty, and each timeout must be at
|
|
||||||
/// least 200 ms.
|
|
||||||
pub open_timeouts: Vec<Duration>,
|
|
||||||
/// Whether opening the session should start a probe test or perform a
|
/// Whether opening the session should start a probe test or perform a
|
||||||
/// negotiation-only no-test exchange.
|
/// negotiation-only no-test exchange.
|
||||||
pub run_mode: RunMode,
|
pub run_mode: RunMode,
|
||||||
/// Policy for server changes to negotiable protocol parameters.
|
|
||||||
pub negotiation_policy: NegotiationPolicy,
|
|
||||||
/// Local UDP socket configuration.
|
|
||||||
pub socket_config: SocketConfig,
|
|
||||||
/// Time after sending a probe before the client reports it as lost.
|
|
||||||
///
|
|
||||||
/// This timeout is local client behavior; it is not negotiated with the
|
|
||||||
/// server. It must be greater than zero.
|
|
||||||
pub probe_timeout: Duration,
|
|
||||||
/// Maximum number of probes tracked as pending/timed-out/completed.
|
|
||||||
///
|
|
||||||
/// This bounds memory used for reply classification and must be greater
|
|
||||||
/// than zero. A very small value can reject sends when replies are still
|
|
||||||
/// outstanding.
|
|
||||||
pub max_pending_probes: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication settings used by client sessions.
|
impl Default for SessionRequest {
|
||||||
///
|
|
||||||
/// `ClientConfig` keeps its existing top-level HMAC field for source
|
|
||||||
/// compatibility. Managed multi-target callers can use this smaller type for
|
|
||||||
/// per-target auth overrides without carrying a full independent
|
|
||||||
/// [`ClientConfig`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct ClientAuthConfig {
|
|
||||||
/// Optional HMAC key used to authenticate IRTT packets.
|
|
||||||
pub hmac_key: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Option<Vec<u8>>> for ClientAuthConfig {
|
|
||||||
fn from(hmac_key: Option<Vec<u8>>) -> Self {
|
|
||||||
Self { hmac_key }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ClientAuthConfig> for Option<Vec<u8>> {
|
|
||||||
fn from(auth: ClientAuthConfig) -> Self {
|
|
||||||
auth.hmac_key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClientConfig {
|
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
server_addr: format!("127.0.0.1:{DEFAULT_PORT}"),
|
|
||||||
duration: Some(DEFAULT_DURATION),
|
duration: Some(DEFAULT_DURATION),
|
||||||
interval: DEFAULT_INTERVAL,
|
interval: DEFAULT_INTERVAL,
|
||||||
length: 0,
|
length: 0,
|
||||||
@@ -159,23 +194,78 @@ impl Default for ClientConfig {
|
|||||||
stamp_at: StampAt::Both,
|
stamp_at: StampAt::Both,
|
||||||
clock: Clock::Both,
|
clock: Clock::Both,
|
||||||
dscp: 0,
|
dscp: 0,
|
||||||
hmac_key: None,
|
|
||||||
server_fill: None,
|
server_fill: None,
|
||||||
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
|
||||||
run_mode: RunMode::Normal,
|
run_mode: RunMode::Normal,
|
||||||
negotiation_policy: NegotiationPolicy::Strict,
|
}
|
||||||
socket_config: SocketConfig::default(),
|
}
|
||||||
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
}
|
||||||
max_pending_probes: DEFAULT_MAX_PENDING,
|
|
||||||
|
/// Opening behavior shared by blocking, async, and managed clients.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OpenPolicy {
|
||||||
|
/// Per-attempt receive timeouts used while opening the session.
|
||||||
|
///
|
||||||
|
/// The client sends an open request for each entry until a valid open reply
|
||||||
|
/// is received. The list must not be empty, and each timeout must be at
|
||||||
|
/// least 200 ms.
|
||||||
|
pub timeouts: Vec<Duration>,
|
||||||
|
/// Policy for server changes to negotiable protocol parameters.
|
||||||
|
pub negotiation: NegotiationPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenPolicy {
|
||||||
|
pub(crate) fn validate(&self) -> Result<(), ClientError> {
|
||||||
|
if self.timeouts.is_empty() {
|
||||||
|
return Err(ClientError::NoOpenTimeouts);
|
||||||
|
}
|
||||||
|
for timeout in &self.timeouts {
|
||||||
|
if *timeout < MIN_OPEN_TIMEOUT {
|
||||||
|
return Err(ClientError::OpenTimeoutTooSmall {
|
||||||
|
timeout: *timeout,
|
||||||
|
minimum: MIN_OPEN_TIMEOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpenPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||||
|
negotiation: NegotiationPolicy::Strict,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local UDP socket options used by [`ClientConfig`].
|
/// Local UDP socket options used by [`ClientConfig`].
|
||||||
///
|
///
|
||||||
/// These settings affect how the client binds, resolves, and receives from the
|
/// These settings affect how the client binds and sends from the socket. They
|
||||||
/// socket. They do not change the IRTT protocol parameters negotiated with the
|
/// do not change the IRTT protocol parameters negotiated with the server.
|
||||||
/// server.
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
///
|
||||||
|
/// use irtt_client::{AddressFamily, ClientConfig, SocketConfig};
|
||||||
|
///
|
||||||
|
/// let config = ClientConfig {
|
||||||
|
/// address_family: AddressFamily::Ipv4,
|
||||||
|
/// socket: SocketConfig {
|
||||||
|
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
|
||||||
|
/// ttl: Some(32),
|
||||||
|
/// ..SocketConfig::default()
|
||||||
|
/// },
|
||||||
|
/// ..ClientConfig::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// assert_eq!(config.socket.ttl, Some(32));
|
||||||
|
/// assert_eq!(config.address_family, AddressFamily::Ipv4);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct SocketConfig {
|
pub struct SocketConfig {
|
||||||
/// Local address to bind before connecting the UDP socket.
|
/// Local address to bind before connecting the UDP socket.
|
||||||
@@ -183,23 +273,21 @@ pub struct SocketConfig {
|
|||||||
/// `None` binds to an unspecified address with an ephemeral port matching
|
/// `None` binds to an unspecified address with an ephemeral port matching
|
||||||
/// the selected remote address family.
|
/// the selected remote address family.
|
||||||
pub bind_addr: Option<SocketAddr>,
|
pub bind_addr: Option<SocketAddr>,
|
||||||
|
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
pub bind_to_device: Option<String>,
|
||||||
|
/// Firewall mark applied to outgoing packets (`SO_MARK`).
|
||||||
|
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||||
|
pub mark: Option<u32>,
|
||||||
|
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
|
pub fib: Option<u32>,
|
||||||
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
||||||
///
|
///
|
||||||
/// `None` leaves the platform default unchanged. Values must fit in the
|
/// `None` leaves the platform default unchanged. Values must fit in the
|
||||||
/// platform socket option range; [`MAX_TTL`] is the public configuration
|
/// platform socket option range; [`MAX_TTL`] is the public configuration
|
||||||
/// bound used by this crate.
|
/// bound used by this crate.
|
||||||
pub ttl: Option<u32>,
|
pub ttl: Option<u32>,
|
||||||
/// Restrict name resolution to IPv4 addresses.
|
|
||||||
pub ipv4_only: bool,
|
|
||||||
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
|
|
||||||
/// sockets.
|
|
||||||
pub ipv6_only: bool,
|
|
||||||
/// Socket read timeout used after the session is open.
|
|
||||||
///
|
|
||||||
/// `None` leaves reads blocking for APIs that perform a single receive.
|
|
||||||
/// Managed sessions may replace `None` or long timeouts with a short
|
|
||||||
/// timeout so cooperative cancellation can be observed promptly.
|
|
||||||
pub recv_timeout: Option<Duration>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How strictly to handle server-side negotiation restrictions.
|
/// How strictly to handle server-side negotiation restrictions.
|
||||||
@@ -209,14 +297,15 @@ pub enum NegotiationPolicy {
|
|||||||
/// than requested.
|
/// than requested.
|
||||||
Strict,
|
Strict,
|
||||||
/// Accept documented server restrictions and report them in
|
/// Accept documented server restrictions and report them in
|
||||||
/// [`NegotiatedParams`](crate::NegotiatedParams).
|
/// [`NegotiationResult`](crate::NegotiationResult).
|
||||||
Loose,
|
Loose,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mode requested during the IRTT open exchange.
|
/// Mode requested during the IRTT open exchange.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunMode {
|
pub enum RunMode {
|
||||||
/// Open a session and send echo probes for the negotiated run duration.
|
/// Open a probe session. The managed driver runs for the negotiated duration;
|
||||||
|
/// low-level adapters leave sending and stopping to the caller.
|
||||||
Normal,
|
Normal,
|
||||||
/// Complete negotiation without running the echo probe test.
|
/// Complete negotiation without running the echo probe test.
|
||||||
NoTest,
|
NoTest,
|
||||||
|
|||||||
@@ -3,35 +3,36 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
use crate::{session::NegotiationResult, timing::ClientTimestamp};
|
||||||
|
|
||||||
/// Result of the IRTT open exchange.
|
/// Successful negotiation with a live session ready for echo probes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SessionStarted {
|
||||||
|
/// Resolved remote socket address.
|
||||||
|
pub remote: SocketAddr,
|
||||||
|
/// Session token used on Echo and Close packets.
|
||||||
|
pub token: u64,
|
||||||
|
pub negotiation: NegotiationResult,
|
||||||
|
/// Client timestamp when the Open reply was accepted.
|
||||||
|
pub at: ClientTimestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful negotiation-only exchange with no live probe session or token.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NoTestCompleted {
|
||||||
|
pub remote: SocketAddr,
|
||||||
|
pub negotiation: NegotiationResult,
|
||||||
|
/// Client timestamp when the Open reply was accepted.
|
||||||
|
pub at: ClientTimestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the IRTT Open exchange, before any echo probes are driven.
|
||||||
///
|
///
|
||||||
/// The lower-level [`Client`](crate::Client) API returns this before any echo
|
/// Managed sessions publish the same payload as a [`ClientEvent`] after Open.
|
||||||
/// probes are driven. Managed sessions publish the contained lifecycle event to
|
|
||||||
/// subscribers immediately after a successful open.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OpenOutcome {
|
pub enum OpenOutcome {
|
||||||
/// A normal probe session was opened.
|
Started(SessionStarted),
|
||||||
Started {
|
NoTestCompleted(NoTestCompleted),
|
||||||
/// Resolved remote socket address used by the UDP socket.
|
|
||||||
remote: SocketAddr,
|
|
||||||
/// Session token assigned by the server and used on echo/close packets.
|
|
||||||
token: u64,
|
|
||||||
/// Parameters returned by the server after negotiation.
|
|
||||||
negotiated: NegotiatedParams,
|
|
||||||
/// Lifecycle event corresponding to this outcome.
|
|
||||||
event: ClientEvent,
|
|
||||||
},
|
|
||||||
/// A no-test open exchange completed without starting a probe session.
|
|
||||||
NoTestCompleted {
|
|
||||||
/// Resolved remote socket address used by the UDP socket.
|
|
||||||
remote: SocketAddr,
|
|
||||||
/// Parameters returned by the server after negotiation.
|
|
||||||
negotiated: NegotiatedParams,
|
|
||||||
/// Lifecycle event corresponding to this outcome.
|
|
||||||
event: ClientEvent,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event emitted by an IRTT client session.
|
/// Event emitted by an IRTT client session.
|
||||||
@@ -44,28 +45,12 @@ pub enum OpenOutcome {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ClientEvent {
|
pub enum ClientEvent {
|
||||||
/// A normal session has opened and is ready to send echo probes.
|
/// A normal session has opened and is ready to send echo probes.
|
||||||
SessionStarted {
|
SessionStarted(SessionStarted),
|
||||||
/// Resolved remote socket address.
|
/// A no-test exchange completed without creating a live session.
|
||||||
remote: SocketAddr,
|
|
||||||
/// Session token assigned by the server.
|
|
||||||
token: u64,
|
|
||||||
/// Negotiated protocol parameters for this session.
|
|
||||||
negotiated: NegotiatedParams,
|
|
||||||
/// Client timestamp when the open reply was accepted.
|
|
||||||
at: ClientTimestamp,
|
|
||||||
},
|
|
||||||
/// A negotiation-only no-test exchange completed.
|
|
||||||
///
|
///
|
||||||
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
|
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
|
||||||
/// expected for this open outcome.
|
/// expected for this outcome.
|
||||||
NoTestCompleted {
|
NoTestCompleted(NoTestCompleted),
|
||||||
/// Resolved remote socket address.
|
|
||||||
remote: SocketAddr,
|
|
||||||
/// Negotiated protocol parameters returned by the server.
|
|
||||||
negotiated: NegotiatedParams,
|
|
||||||
/// Client timestamp when the open reply was accepted.
|
|
||||||
at: ClientTimestamp,
|
|
||||||
},
|
|
||||||
/// The session closed locally or through an authenticated peer close.
|
/// The session closed locally or through an authenticated peer close.
|
||||||
///
|
///
|
||||||
/// For a peer close-flagged echo reply, the reply's measurement or
|
/// For a peer close-flagged echo reply, the reply's measurement or
|
||||||
@@ -92,8 +77,9 @@ pub enum ClientEvent {
|
|||||||
seq: u32,
|
seq: u32,
|
||||||
/// Resolved remote socket address.
|
/// Resolved remote socket address.
|
||||||
remote: SocketAddr,
|
remote: SocketAddr,
|
||||||
/// Monotonic deadline at which the probe was scheduled to be sent.
|
/// Intended send deadline, supplied by the managed runner.
|
||||||
scheduled_at: Instant,
|
/// Low-level adapters leave this absent.
|
||||||
|
scheduled_at: Option<Instant>,
|
||||||
/// Paired client wall/monotonic timestamp captured immediately after
|
/// Paired client wall/monotonic timestamp captured immediately after
|
||||||
/// the successful UDP send completed. This is the RTT and userspace
|
/// the successful UDP send completed. This is the RTT and userspace
|
||||||
/// upstream one-way-delay fallback endpoint. The operational probe
|
/// upstream one-way-delay fallback endpoint. The operational probe
|
||||||
@@ -106,8 +92,8 @@ pub enum ClientEvent {
|
|||||||
/// scheduler commit work.
|
/// scheduler commit work.
|
||||||
send_call: Duration,
|
send_call: Duration,
|
||||||
/// Absolute difference between the scheduled send time and the
|
/// Absolute difference between the scheduled send time and the
|
||||||
/// post-send `sent_at` timestamp above.
|
/// post-send `sent_at` timestamp above; absent for low-level sends.
|
||||||
timer_error: Duration,
|
timer_error: Option<Duration>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// First in-window reply for a pending echo request.
|
/// First in-window reply for a pending echo request.
|
||||||
@@ -260,6 +246,19 @@ pub struct RttSample {
|
|||||||
/// processing time or comparing wall-clock timestamps from different hosts. It
|
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||||
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||||
/// which is non-negative and represented by [`Duration`].
|
/// which is non-negative and represented by [`Duration`].
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// Signed timing values retain a negative result instead of clamping clock
|
||||||
|
/// skew or server processing adjustments to zero:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use irtt_client::SignedDuration;
|
||||||
|
///
|
||||||
|
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
|
||||||
|
/// assert!(adjusted_rtt.is_negative());
|
||||||
|
/// assert_eq!(adjusted_rtt.as_micros(), -250);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct SignedDuration {
|
pub struct SignedDuration {
|
||||||
ns: i128,
|
ns: i128,
|
||||||
@@ -351,7 +350,22 @@ pub struct OneWayDelaySample {
|
|||||||
/// Client-to-server delay.
|
/// Client-to-server delay.
|
||||||
///
|
///
|
||||||
/// This is computed from the client send wall time and the server receive
|
/// This is computed from the client send wall time and the server receive
|
||||||
/// wall time when both are available.
|
/// wall time when both are available. The client send endpoint is the
|
||||||
|
/// correlated Linux kernel TX timestamp for this send when one is
|
||||||
|
/// available and plausible for this send, and the userspace post-send wall
|
||||||
|
/// time otherwise. The server receive endpoint is whatever wall time the
|
||||||
|
/// peer reported; against this crate's own Tokio server runtime on Linux
|
||||||
|
/// (the `irtt-server` applet), that is its kernel receive timestamp when
|
||||||
|
/// one is available and plausible, and its userspace receive wall time
|
||||||
|
/// otherwise. Against another IRTT-compatible peer, or an application
|
||||||
|
/// driving `irtt_server::ServerCore::handle_datagram` directly, the
|
||||||
|
/// client has no way to know which clock source produced it — the public
|
||||||
|
/// `ServerCore` API never supplies a kernel timestamp of its own accord.
|
||||||
|
/// `RttSample::raw` is unaffected and remains a userspace
|
||||||
|
/// monotonic measurement, but `adjusted` and `effective` can still shift:
|
||||||
|
/// when the session negotiated wall-clock timing rather than monotonic,
|
||||||
|
/// server processing time is derived from this same server receive
|
||||||
|
/// endpoint.
|
||||||
pub client_to_server: Option<SignedDuration>,
|
pub client_to_server: Option<SignedDuration>,
|
||||||
/// Server-to-client delay.
|
/// Server-to-client delay.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -13,15 +13,43 @@
|
|||||||
//! also signed when the required wall-clock timestamps are available; negative
|
//! also signed when the required wall-clock timestamps are available; negative
|
||||||
//! values usually indicate client/server clock skew.
|
//! values usually indicate client/server clock skew.
|
||||||
//!
|
//!
|
||||||
//! [`Client`] is the runtime-free low-level blocking adapter. With the
|
//! [`Client`] is the runtime-free low-level blocking adapter and requires no
|
||||||
//! `tokio` feature, `AsyncClient` provides the corresponding Tokio adapter;
|
//! Tokio dependency at all; it is what a caller without a runtime, or that
|
||||||
//! [`managed`] contains the unified managed task/handle implementation,
|
//! wants full control over its own event loop, should start from. With the
|
||||||
//! including the dedicated-runtime blocking frontend for synchronous callers.
|
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
||||||
|
//! adapter for a caller that owns a runtime and drives socket readiness
|
||||||
|
//! itself. Both adapters send one probe whenever called; callers own cadence
|
||||||
|
//! 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:
|
||||||
|
//! `ManagedClientTask` /
|
||||||
|
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
||||||
|
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
|
||||||
|
//! feature) wraps that in a synchronous owner backed by its own dedicated
|
||||||
|
//! current-thread runtime for callers that want managed multi-target
|
||||||
|
//! behavior without touching Tokio themselves.
|
||||||
|
//!
|
||||||
|
//! A successful [`OpenOutcome`] carries either [`SessionStarted`] (with a live
|
||||||
|
//! token) or [`NoTestCompleted`] (terminal, with no probe session). Lifecycle
|
||||||
|
//! events use these same payload types. Their [`NegotiationResult`] separates
|
||||||
|
//! typed [`AcceptedSessionParameters`] from the exact decoded peer `Params` and
|
||||||
|
//! accepted [`NegotiationChange`] records. Use `accepted.interval` and
|
||||||
|
//! `accepted.duration` for cadence and lifetime, `accepted.dscp` for the public
|
||||||
|
//! DSCP codepoint, and `peer_params` for wire diagnostics. Strict/Loose policy
|
||||||
|
//! compares the actual request and reply wire values before deriving accepted
|
||||||
|
//! semantics. Both adapters' `negotiation()` accessors return only a live
|
||||||
|
//! session's result; no-test completion leaves no current session.
|
||||||
|
//!
|
||||||
|
//! See `examples/` in the repository for a runnable example of each tier.
|
||||||
//!
|
//!
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
#[cfg(feature = "tokio")]
|
#[cfg(feature = "tokio")]
|
||||||
mod async_client;
|
mod async_client;
|
||||||
|
mod auth;
|
||||||
mod client;
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
@@ -30,6 +58,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;
|
||||||
@@ -37,15 +66,18 @@ mod timing;
|
|||||||
|
|
||||||
#[cfg(feature = "tokio")]
|
#[cfg(feature = "tokio")]
|
||||||
pub use async_client::AsyncClient;
|
pub use async_client::AsyncClient;
|
||||||
|
pub use auth::{Authentication, HmacKey};
|
||||||
pub use client::Client;
|
pub use client::Client;
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
|
AddressFamily, ClientConfig, NegotiationPolicy, OpenPolicy, RecvBudget, RunMode,
|
||||||
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
SessionRequest, SocketConfig, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL,
|
||||||
|
MAX_UDP_PAYLOAD_LENGTH,
|
||||||
};
|
};
|
||||||
pub use error::ClientError;
|
pub use error::ClientError;
|
||||||
pub use event::{
|
pub use event::{
|
||||||
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample,
|
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
|
||||||
ServerTiming, SignedDuration, WarningKind,
|
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
pub use session::{NegotiatedParams, NegotiationRestriction};
|
pub use send::{SendProbeError, SendReceipt};
|
||||||
|
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
||||||
pub use timing::ClientTimestamp;
|
pub use timing::ClientTimestamp;
|
||||||
@@ -157,22 +157,14 @@ impl BlockingManagedClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||||
join_worker(
|
self.worker
|
||||||
self.worker
|
.take()
|
||||||
.take()
|
.expect("blocking managed worker is joined at most once")
|
||||||
.expect("blocking managed worker is joined at most once"),
|
.join()
|
||||||
)
|
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn join_worker(
|
|
||||||
worker: JoinHandle<ManagedOutcome>,
|
|
||||||
) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
|
||||||
worker
|
|
||||||
.join()
|
|
||||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for BlockingManagedClient {
|
impl Drop for BlockingManagedClient {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.worker.is_some() {
|
if self.worker.is_some() {
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ pub use model::*;
|
|||||||
#[cfg(feature = "tokio")]
|
#[cfg(feature = "tokio")]
|
||||||
pub use task::*;
|
pub use task::*;
|
||||||
|
|
||||||
#[cfg(all(feature = "tokio", test))]
|
#[cfg(feature = "tokio")]
|
||||||
mod blocking_tests;
|
mod schedule;
|
||||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{ClientAuthConfig, ClientConfig, ClientError, ClientEvent};
|
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
||||||
|
|
||||||
use super::TargetId;
|
use super::TargetId;
|
||||||
|
|
||||||
@@ -26,12 +26,34 @@ pub struct TargetInstance {
|
|||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Endpoint and optional authentication override for one managed target.
|
/// Authentication specification for a managed target.
|
||||||
|
///
|
||||||
|
/// Equality preserves the specification: inheritance and an explicit override
|
||||||
|
/// remain distinct even when they currently resolve to the same authentication.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum TargetAuth {
|
||||||
|
/// Use the shared client configuration's authentication.
|
||||||
|
#[default]
|
||||||
|
Inherit,
|
||||||
|
/// Replace shared authentication. `Unauthenticated` explicitly disables it.
|
||||||
|
Override(Authentication),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetAuth {
|
||||||
|
pub(crate) fn resolve(&self, shared: &Authentication) -> Authentication {
|
||||||
|
match self {
|
||||||
|
Self::Inherit => shared.clone(),
|
||||||
|
Self::Override(auth) => auth.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Endpoint 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,
|
||||||
pub auth: Option<ClientAuthConfig>,
|
pub auth: TargetAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedTargetConfig {
|
impl ManagedTargetConfig {
|
||||||
@@ -39,7 +61,7 @@ impl ManagedTargetConfig {
|
|||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
server_addr: server_addr.into(),
|
server_addr: server_addr.into(),
|
||||||
auth: None,
|
auth: TargetAuth::Inherit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +85,8 @@ pub enum ManagedCompletionPolicy {
|
|||||||
/// Shared configuration for one managed task.
|
/// Shared configuration for one managed task.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedClientConfig {
|
pub struct ManagedClientConfig {
|
||||||
|
/// Reusable session/socket configuration shared across target generations.
|
||||||
|
/// Each target supplies its endpoint and resolves its authentication policy.
|
||||||
pub client: ClientConfig,
|
pub client: ClientConfig,
|
||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
pub completion: ManagedCompletionPolicy,
|
pub completion: ManagedCompletionPolicy,
|
||||||
@@ -117,6 +141,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>,
|
||||||
}
|
}
|
||||||
@@ -125,6 +155,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,
|
||||||
@@ -141,9 +175,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)]
|
||||||
@@ -449,199 +495,3 @@ pub(crate) fn classify_client_error(
|
|||||||
message: Arc::from(error.to_string()),
|
message: Arc::from(error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::{collections::TryReserveError, io};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn io_error() -> io::Error {
|
|
||||||
io::Error::other("boom")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_reserve_error() -> TryReserveError {
|
|
||||||
Vec::<u8>::new().try_reserve(usize::MAX).unwrap_err()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classification_cases() -> Vec<(ClientError, ManagedTargetFailureKind)> {
|
|
||||||
vec![
|
|
||||||
(
|
|
||||||
ClientError::Resolve {
|
|
||||||
addr: "example.invalid:2112".to_owned(),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Resolve,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::Socket(io_error()),
|
|
||||||
ManagedTargetFailureKind::Socket,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::DatagramLengthMismatch {
|
|
||||||
expected: 64,
|
|
||||||
actual: 32,
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Socket,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::SocketOption {
|
|
||||||
operation: "set read timeout",
|
|
||||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
|
||||||
source: io_error(),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::SocketOption,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::ReadTimeoutRestore { source: io_error() },
|
|
||||||
ManagedTargetFailureKind::SocketOption,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::Protocol(irtt_proto::ProtoError::BadMagic),
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::ProtocolVersionMismatch {
|
|
||||||
requested: 1,
|
|
||||||
received: 2,
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(ClientError::ZeroToken, ManagedTargetFailureKind::Protocol),
|
|
||||||
(
|
|
||||||
ClientError::UnexpectedNoTestReply,
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::NonZeroNoTestToken { token: 7 },
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::ServerRejected,
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::NegotiationRejected {
|
|
||||||
reason: "duration".to_owned(),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Protocol,
|
|
||||||
),
|
|
||||||
(ClientError::OpenTimeout, ManagedTargetFailureKind::Timeout),
|
|
||||||
(
|
|
||||||
ClientError::InvalidConfig {
|
|
||||||
reason: "interval".to_owned(),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Configuration,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::OpenTimeoutTooSmall {
|
|
||||||
timeout: Duration::from_millis(1),
|
|
||||||
minimum: Duration::from_millis(10),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::Configuration,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::NoOpenTimeouts,
|
|
||||||
ManagedTargetFailureKind::Configuration,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::AllocationFailed {
|
|
||||||
operation: "pending probes",
|
|
||||||
source: try_reserve_error(),
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::ResourceExhausted,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::CounterOverflow {
|
|
||||||
counter: "packets_sent",
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::ResourceExhausted,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::DurationOverflow,
|
|
||||||
ManagedTargetFailureKind::ResourceExhausted,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::PendingLimitExceeded { limit: 8 },
|
|
||||||
ManagedTargetFailureKind::ResourceExhausted,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::NoTokioRuntime,
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
(ClientError::NotOpen, ManagedTargetFailureKind::InvalidState),
|
|
||||||
(
|
|
||||||
ClientError::AlreadyOpen,
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::AlreadyCompleted,
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::AlreadyClosed,
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::StalePreparedProbe {
|
|
||||||
prepared_seq: 3,
|
|
||||||
next_wire_seq: 4,
|
|
||||||
},
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ClientError::PendingSequenceCollision { seq: 9 },
|
|
||||||
ManagedTargetFailureKind::InvalidState,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_client_error_classifies_to_its_documented_kind() {
|
|
||||||
for (error, expected) in classification_cases() {
|
|
||||||
let failure = classify_client_error(ManagedTargetFailurePhase::Opening, &error);
|
|
||||||
assert_eq!(
|
|
||||||
failure.kind, expected,
|
|
||||||
"unexpected classification for {error:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(failure.phase, ManagedTargetFailurePhase::Opening);
|
|
||||||
assert_eq!(&*failure.message, error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classification_preserves_the_reported_phase() {
|
|
||||||
for phase in [
|
|
||||||
ManagedTargetFailurePhase::Connecting,
|
|
||||||
ManagedTargetFailurePhase::Opening,
|
|
||||||
ManagedTargetFailurePhase::Sending,
|
|
||||||
ManagedTargetFailurePhase::Receiving,
|
|
||||||
ManagedTargetFailurePhase::Timing,
|
|
||||||
ManagedTargetFailurePhase::Closing,
|
|
||||||
] {
|
|
||||||
let failure = classify_client_error(phase, &ClientError::OpenTimeout);
|
|
||||||
assert_eq!(failure.phase, phase);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn phase_and_kind_display_without_debug_spelling() {
|
|
||||||
assert_eq!(
|
|
||||||
ManagedTargetFailurePhase::Connecting.to_string(),
|
|
||||||
"connecting"
|
|
||||||
);
|
|
||||||
assert_eq!(ManagedTargetFailurePhase::Closing.to_string(), "closing");
|
|
||||||
assert_eq!(
|
|
||||||
ManagedTargetFailureKind::SocketOption.to_string(),
|
|
||||||
"socket option"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ManagedTargetFailureKind::ResourceExhausted.to_string(),
|
|
||||||
"resource exhausted"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ManagedTargetFailureKind::InvalidState.to_string(),
|
|
||||||
"invalid state"
|
|
||||||
);
|
|
||||||
assert_eq!(ManagedTargetFailureKind::Other.to_string(), "other");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::{error::ClientError, session::AcceptedSessionParameters};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct ProbeSchedule {
|
||||||
|
end_at: Option<Instant>,
|
||||||
|
interval: Duration,
|
||||||
|
next_send_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct ScheduleCommit {
|
||||||
|
pub(crate) scheduled_at: Instant,
|
||||||
|
pub(crate) next_send_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeSchedule {
|
||||||
|
pub(crate) fn new(
|
||||||
|
start_at: Instant,
|
||||||
|
accepted: &AcceptedSessionParameters,
|
||||||
|
) -> Result<Self, ClientError> {
|
||||||
|
let interval = accepted.interval;
|
||||||
|
let end_at = accepted
|
||||||
|
.duration
|
||||||
|
.map(|duration| {
|
||||||
|
start_at
|
||||||
|
.checked_add(duration)
|
||||||
|
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||||
|
reason: "duration is too large to schedule".to_owned(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
end_at,
|
||||||
|
interval,
|
||||||
|
next_send_at: Some(start_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||||
|
self.next_send_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake for finite expiry independently of send readiness until finished.
|
||||||
|
pub(crate) fn end_deadline(&self) -> Option<Instant> {
|
||||||
|
self.end_at.filter(|_| !self.is_finished())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn interval(&self) -> Duration {
|
||||||
|
self.interval
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||||
|
if self.next_send_at.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.end_at.is_some_and(|end| now >= end) {
|
||||||
|
self.next_send_at = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn preflight_managed_commit(
|
||||||
|
&self,
|
||||||
|
scheduled_at: Instant,
|
||||||
|
permission_at: Instant,
|
||||||
|
) -> Result<ScheduleCommit, ClientError> {
|
||||||
|
let (scheduled_at, next_send_at) =
|
||||||
|
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||||
|
Ok(ScheduleCommit {
|
||||||
|
scheduled_at,
|
||||||
|
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(next_send_at)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||||
|
self.next_send_at = commit.next_send_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclaim slots crossed while a successful socket submission was in flight.
|
||||||
|
/// This follows the send commit; it never advances a failed submission.
|
||||||
|
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||||
|
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
|
||||||
|
let commit = self.preflight_managed_commit(deadline, now)?;
|
||||||
|
self.commit(commit);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_finished(&self) -> bool {
|
||||||
|
self.next_send_at.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn advance_cadence(
|
||||||
|
deadline: Instant,
|
||||||
|
interval: Duration,
|
||||||
|
now: Instant,
|
||||||
|
) -> Result<(Instant, Instant), ClientError> {
|
||||||
|
if interval.is_zero() {
|
||||||
|
return Err(ClientError::InvalidConfig {
|
||||||
|
reason: "probe interval must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed_slots = now
|
||||||
|
.checked_duration_since(deadline)
|
||||||
|
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||||
|
let scheduled_offset = duration_from_nanos(
|
||||||
|
interval
|
||||||
|
.as_nanos()
|
||||||
|
.checked_mul(elapsed_slots)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?,
|
||||||
|
)?;
|
||||||
|
let next_offset = duration_from_nanos(
|
||||||
|
interval
|
||||||
|
.as_nanos()
|
||||||
|
.checked_mul(
|
||||||
|
elapsed_slots
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?,
|
||||||
|
)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?,
|
||||||
|
)?;
|
||||||
|
let scheduled_at = deadline
|
||||||
|
.checked_add(scheduled_offset)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?;
|
||||||
|
let next_at = deadline
|
||||||
|
.checked_add(next_offset)
|
||||||
|
.ok_or(ClientError::DurationOverflow)?;
|
||||||
|
Ok((scheduled_at, next_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||||
|
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||||
|
let seconds =
|
||||||
|
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||||
|
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||||
|
.expect("nanosecond remainder is always less than one second");
|
||||||
|
Ok(Duration::new(seconds, subsec_nanos))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||||
|
left.checked_duration_since(right)
|
||||||
|
.or_else(|| right.checked_duration_since(left))
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let end = start + Duration::from_millis(500);
|
||||||
|
let mut accepted = AcceptedSessionParameters {
|
||||||
|
duration: Some(Duration::from_millis(500)),
|
||||||
|
interval: Duration::from_millis(1),
|
||||||
|
length: 0,
|
||||||
|
received_stats: irtt_proto::ReceivedStats::None,
|
||||||
|
stamp_at: irtt_proto::StampAt::None,
|
||||||
|
clock: irtt_proto::Clock::Both,
|
||||||
|
dscp: 0,
|
||||||
|
server_fill: None,
|
||||||
|
};
|
||||||
|
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||||
|
assert_eq!(finite.end_deadline(), Some(end));
|
||||||
|
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
|
||||||
|
assert_eq!(finite.end_deadline(), Some(end));
|
||||||
|
assert!(!finite.permit_probe_at(end));
|
||||||
|
assert!(finite.is_finished());
|
||||||
|
assert_eq!(finite.end_deadline(), None);
|
||||||
|
|
||||||
|
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||||
|
let commit = finite
|
||||||
|
.preflight_managed_commit(start, end - Duration::from_nanos(1))
|
||||||
|
.unwrap();
|
||||||
|
finite.commit(commit);
|
||||||
|
assert!(finite.is_finished());
|
||||||
|
assert_eq!(finite.end_deadline(), None);
|
||||||
|
|
||||||
|
accepted.duration = None;
|
||||||
|
let mut continuous = ProbeSchedule::new(start, &accepted).unwrap();
|
||||||
|
assert!(continuous.permit_probe_at(end));
|
||||||
|
assert_eq!(continuous.end_deadline(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
-1971
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,511 @@
|
|||||||
|
//! 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.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.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(),
|
||||||
|
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,365 @@
|
|||||||
|
//! 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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user