Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb8bea4e6 | ||
|
|
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 |
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:
|
||||||
@@ -32,7 +31,7 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
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
|
||||||
@@ -52,7 +51,7 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
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:
|
||||||
@@ -137,7 +112,7 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
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: |
|
||||||
@@ -174,7 +149,7 @@ jobs:
|
|||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Proto Fuzz Smoke
|
name: Fuzz Smoke
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -16,6 +16,9 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
||||||
CARGO_FUZZ_VERSION: 0.13.1
|
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:
|
jobs:
|
||||||
fuzz-smoke:
|
fuzz-smoke:
|
||||||
@@ -32,22 +35,33 @@ jobs:
|
|||||||
- name: Install stable Rust toolchain
|
- name: Install stable Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
with:
|
with:
|
||||||
cache-key: fuzz
|
cache: false
|
||||||
|
|
||||||
- name: Install nightly Rust toolchain
|
- name: Install nightly Rust toolchain
|
||||||
# Installed alongside, but deliberately not made the directory
|
# Installed alongside, but deliberately not made the directory
|
||||||
# default: cargo-fuzz itself is built with the default stable
|
# default: cargo-fuzz itself is built with the default stable
|
||||||
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
||||||
# `+nightly`. Building cargo-fuzz's own dependency tree under a
|
# the GNU-host nightly below. Building cargo-fuzz's own dependency
|
||||||
# nightly override can trip build scripts (e.g. rustix) that gate
|
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
|
||||||
# unstable internal attributes on detecting a nightly compiler.
|
# unstable internal attributes on detecting a nightly compiler.
|
||||||
run: rustup toolchain install nightly --profile minimal --no-self-update
|
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
|
||||||
|
|
||||||
- name: Install cargo-fuzz
|
- name: Install cargo-fuzz
|
||||||
run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}"
|
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
|
- name: Build all fuzz targets
|
||||||
run: cargo +nightly fuzz build
|
# 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
|
working-directory: fuzz
|
||||||
|
|
||||||
- name: Run bounded fuzz smoke for every target
|
- name: Run bounded fuzz smoke for every target
|
||||||
@@ -57,9 +71,9 @@ jobs:
|
|||||||
# regression; this is not meant to be exhaustive fuzzing.
|
# regression; this is not meant to be exhaustive fuzzing.
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
|
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
|
||||||
echo "::group::fuzz smoke: ${target}"
|
echo "::group::fuzz smoke: ${target}"
|
||||||
cargo +nightly fuzz run "${target}" -- \
|
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||||
-max_total_time=10 \
|
-max_total_time=10 \
|
||||||
-timeout=5
|
-timeout=5
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
@@ -32,13 +32,14 @@ jobs:
|
|||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
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: |
|
||||||
@@ -62,14 +63,14 @@ jobs:
|
|||||||
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@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# The CLI preserves the previous wrapper's `feature-group: all-features`
|
||||||
|
# behavior without its GitHub API/cache dependency.
|
||||||
|
- name: Install cargo-semver-checks
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" cargo-semver-checks
|
||||||
|
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Check semver compatibility
|
||||||
|
run: cargo semver-checks check-release --all-features
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
name: Supply chain
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: "41 2 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ACTIONLINT_VERSION: 1.7.7
|
||||||
|
CARGO_DENY_VERSION: 0.20.2
|
||||||
|
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
|
||||||
|
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cargo-deny:
|
||||||
|
name: cargo-deny ${{ matrix.checks }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Advisories can appear after code was merged. Keep this visible,
|
||||||
|
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||||
|
- checks: advisories
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# These are policy choices in this repo. They should block PRs.
|
||||||
|
- checks: bans licenses sources
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
continue-on-error: ${{ matrix.continue-on-error }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install cargo-deny
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" --strip-components=1 \
|
||||||
|
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
|
||||||
|
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run cargo-deny
|
||||||
|
run: >-
|
||||||
|
cargo deny --manifest-path Cargo.toml --all-features check
|
||||||
|
${{ matrix.checks }}
|
||||||
|
|
||||||
|
actionlint:
|
||||||
|
name: actionlint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install actionlint
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||||
|
curl --fail --show-error --location --retry 3 \
|
||||||
|
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
|
||||||
|
--output "$archive"
|
||||||
|
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
|
||||||
|
tar -xzf "$archive" actionlint
|
||||||
|
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
|
||||||
|
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: actionlint handwritten workflows
|
||||||
|
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
|
||||||
|
# required absolute action URL in interop-netem.yml, so that workflow
|
||||||
|
# is checked by the Gitea runner instead.
|
||||||
|
run: |
|
||||||
|
actionlint \
|
||||||
|
.gitea/workflows/ci.yml \
|
||||||
|
.gitea/workflows/docker-publish.yml \
|
||||||
|
.gitea/workflows/fuzz.yml \
|
||||||
|
.gitea/workflows/semver.yml \
|
||||||
|
.gitea/workflows/supply-chain.yml \
|
||||||
|
.github/workflows/actions-security.yml \
|
||||||
|
.github/workflows/ci-arm.yml \
|
||||||
|
.github/workflows/osv-scanner.yml \
|
||||||
|
.github/workflows/rust-clippy.yml
|
||||||
|
|
||||||
|
- name: actionlint generated release workflow
|
||||||
|
run: >-
|
||||||
|
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
|
||||||
@@ -16,34 +16,6 @@ permissions:
|
|||||||
contents: read
|
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-rs-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@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||||
|
with:
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.task }} with all features
|
||||||
|
run: |
|
||||||
|
cargo ${{ matrix.task }} \
|
||||||
|
--workspace \
|
||||||
|
--all-targets \
|
||||||
|
--all-features \
|
||||||
|
--target ${{ matrix.target }} \
|
||||||
|
--locked
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.task }} without default features
|
||||||
|
run: |
|
||||||
|
cargo ${{ matrix.task }} \
|
||||||
|
--workspace \
|
||||||
|
--all-targets \
|
||||||
|
--no-default-features \
|
||||||
|
--target ${{ matrix.target }} \
|
||||||
|
--locked
|
||||||
@@ -31,7 +31,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
scan-scheduled:
|
scan-scheduled:
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||||
with:
|
with:
|
||||||
# Example of specifying custom arguments
|
# Example of specifying custom arguments
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
./
|
./
|
||||||
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:
|
with:
|
||||||
# Example of specifying custom arguments
|
# Example of specifying custom arguments
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
|
|||||||
@@ -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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # 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-rs/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-rs-release.yml
|
|
||||||
shellcheck: false
|
|
||||||
@@ -173,6 +173,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-client
|
## irtt-client
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
|
||||||
|
|
||||||
### 0.5.0
|
### 0.5.0
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -50,6 +56,12 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-server
|
## irtt-server
|
||||||
|
|
||||||
|
### 0.5.2
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
|
||||||
|
|
||||||
### 0.5.1
|
### 0.5.1
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -96,6 +108,28 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-rs
|
## irtt-rs
|
||||||
|
|
||||||
|
### 0.7.0
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
|
||||||
|
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
|
||||||
|
|
||||||
|
### 0.6.2
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
|
||||||
|
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
|
||||||
|
|
||||||
### 0.6.1
|
### 0.6.1
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
Generated
+29
-29
@@ -242,7 +242,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -385,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",
|
||||||
@@ -395,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.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -496,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"
|
||||||
@@ -710,12 +710,12 @@ dependencies = [
|
|||||||
"indoc",
|
"indoc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
@@ -749,7 +749,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.6.1"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -764,7 +764,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-server"
|
name = "irtt-server"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
@@ -881,15 +881,15 @@ 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.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
@@ -1185,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
"rand 0.8.7",
|
"rand 0.8.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1294,9 +1294,9 @@ 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 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
@@ -1569,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1705,9 +1705,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.3"
|
version = "3.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1840,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1886,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1944,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.1"
|
version = "1.26.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic",
|
"atomic",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
|
|||||||
+2
-2
@@ -18,8 +18,8 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
irtt-proto = { version = "0.5.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.1", 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]
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Builds the irtt-server applet only, statically linked against musl, and
|
||||||
|
# ships it in a scratch image with no shell or libc.
|
||||||
|
#
|
||||||
|
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
|
||||||
|
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
|
||||||
|
# picks the right musl target triple, sysroot, and linker for whatever
|
||||||
|
# platform buildx asked for.
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
|
||||||
|
|
||||||
|
COPY --from=xx / /
|
||||||
|
|
||||||
|
RUN apk add --no-cache clang lld file
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
RUN xx-apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
|
||||||
|
RUN xx-cargo build \
|
||||||
|
--release \
|
||||||
|
--locked \
|
||||||
|
-p irtt-rs \
|
||||||
|
--bin irtt-server \
|
||||||
|
--no-default-features \
|
||||||
|
--features server \
|
||||||
|
--target-dir /build/target \
|
||||||
|
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
|
||||||
|
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=builder /irtt-server /irtt-server
|
||||||
|
|
||||||
|
ENTRYPOINT ["/irtt-server"]
|
||||||
@@ -171,6 +171,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
|
|||||||
```sh
|
```sh
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo test --workspace --locked
|
cargo test --workspace --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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.6.1"
|
version = "0.7.0"
|
||||||
description = "IRTT-compatible client, TUI, and server applications"
|
description = "IRTT-compatible client, TUI, and server applications"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -47,9 +47,9 @@ server = ["dep:irtt-server", "dep:tokio"]
|
|||||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
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.6", 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 }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
|||||||
match applet_basename(argv0) {
|
match applet_basename(argv0) {
|
||||||
"irtt-client" => Some(RequestedApplet::Client),
|
"irtt-client" => Some(RequestedApplet::Client),
|
||||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||||
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
"irtt-server" => Some(RequestedApplet::Server),
|
||||||
"irtt-rs" => None,
|
"irtt-rs" => None,
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -199,6 +199,7 @@ mod tests {
|
|||||||
detect_applet_from_argv0("irtt-server"),
|
detect_applet_from_argv0("irtt-server"),
|
||||||
Some(RequestedApplet::Server)
|
Some(RequestedApplet::Server)
|
||||||
);
|
);
|
||||||
|
assert_eq!(detect_applet_from_argv0("irttd"), None);
|
||||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
|||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||||
pub struct ClientArgs {
|
pub struct ClientArgs {
|
||||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
value_name = "TARGET",
|
value_name = "TARGET",
|
||||||
num_args = 0..,
|
num_args = 0..,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL=. 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 host-a:2112 eu=host-b:2112"
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||||
)]
|
)]
|
||||||
pub targets: Vec<TargetArg>,
|
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.
|
/// Managed group pacing for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
@@ -34,7 +41,7 @@ pub struct ClientArgs {
|
|||||||
default_value = "10s",
|
default_value = "10s",
|
||||||
value_parser = parse_test_duration,
|
value_parser = parse_test_duration,
|
||||||
help = "Test duration; use 0 for continuous mode",
|
help = "Test duration; use 0 for continuous mode",
|
||||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
|
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||||
)]
|
)]
|
||||||
pub duration: Duration,
|
pub duration: Duration,
|
||||||
|
|
||||||
@@ -86,12 +93,16 @@ impl ClientArgs {
|
|||||||
/// Targets are only required here. `--list-columns` returns before this is
|
/// Targets are only required here. `--list-columns` returns before this is
|
||||||
/// called, so listing columns still needs no target.
|
/// called, so listing columns still needs no target.
|
||||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
pub 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(
|
prepare_managed_run(
|
||||||
&self.common,
|
&self.common,
|
||||||
self.duration,
|
self.duration,
|
||||||
TargetSelection {
|
TargetSelection {
|
||||||
targets: &self.targets,
|
targets: &self.targets,
|
||||||
pacing: self.pacing,
|
pacing: self.pacing,
|
||||||
|
stdin_controlled: self.targets_stdin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,14 +170,14 @@ mod tests {
|
|||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||||
|
|
||||||
assert_eq!(args.targets.len(), 1);
|
assert_eq!(args.targets.len(), 1);
|
||||||
assert_eq!(args.targets[0].label, None);
|
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||||
assert_eq!(args.interval, Duration::from_secs(1));
|
assert_eq!(args.interval, Duration::from_secs(1));
|
||||||
assert_eq!(args.format, OutputFormat::Table);
|
assert_eq!(args.format, OutputFormat::Table);
|
||||||
assert_eq!(args.columns, None);
|
assert_eq!(args.columns, None);
|
||||||
assert_eq!(args.header, HeaderMode::Auto);
|
assert_eq!(args.header, HeaderMode::Auto);
|
||||||
|
assert!(!args.targets_stdin);
|
||||||
assert!(!args.is_continuous());
|
assert!(!args.is_continuous());
|
||||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||||
|
|
||||||
@@ -254,8 +265,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_labelled_target_syntax_is_rejected() {
|
fn invalid_labelled_target_syntax_is_rejected() {
|
||||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||||
assert!(parse(&["label="]).is_err());
|
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -309,6 +320,21 @@ mod tests {
|
|||||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||||
|
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||||
|
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||||
|
let setup = args.prepare().unwrap();
|
||||||
|
assert!(setup.targets.is_empty());
|
||||||
|
assert!(setup.stdin_controlled);
|
||||||
|
let config = setup.managed_config();
|
||||||
|
assert_eq!(
|
||||||
|
config.completion,
|
||||||
|
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||||
|
);
|
||||||
|
assert_eq!(config.max_live_target_generations, 256);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_format_summary_policy_is_table_only() {
|
fn output_format_summary_policy_is_table_only() {
|
||||||
assert!(OutputFormat::Table.prints_summary());
|
assert!(OutputFormat::Table.prints_summary());
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
||||||
io::{self, Write},
|
io::{self, BufRead, Write},
|
||||||
sync::atomic::AtomicBool,
|
sync::{atomic::AtomicBool, Arc, Mutex},
|
||||||
thread,
|
thread,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{
|
managed::{
|
||||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
|
||||||
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
|
||||||
|
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||||
},
|
},
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
};
|
};
|
||||||
@@ -20,13 +20,13 @@ use super::{
|
|||||||
output::{EventRenderStats, OutputConfig},
|
output::{EventRenderStats, OutputConfig},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::shared::client::expected_probe_count;
|
|
||||||
use crate::shared::client::{
|
use crate::shared::client::{
|
||||||
is_shutdown_requested,
|
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
|
||||||
session::{
|
session::{
|
||||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||||
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
||||||
},
|
},
|
||||||
|
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
use irtt_stats::{StatsCollector, StatsConfig};
|
||||||
@@ -41,6 +41,7 @@ const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
|||||||
|
|
||||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||||
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||||
|
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
pub fn run_stream(
|
pub fn run_stream(
|
||||||
args: ClientArgs,
|
args: ClientArgs,
|
||||||
@@ -61,6 +62,9 @@ pub fn run_stream(
|
|||||||
args.verbose,
|
args.verbose,
|
||||||
)
|
)
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
.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 continuous = args.is_continuous();
|
||||||
let target_count = setup.target_count();
|
let target_count = setup.target_count();
|
||||||
|
|
||||||
@@ -196,6 +200,561 @@ pub fn run_stream(
|
|||||||
Ok(())
|
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>(
|
fn process_event<W: Write>(
|
||||||
event: ManagedEvent,
|
event: ManagedEvent,
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
stream_output: &mut StreamOutput<'_, W>,
|
||||||
@@ -384,9 +943,218 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use irtt_client::managed::{
|
use irtt_client::managed::{
|
||||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
|
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
|
||||||
|
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::{io::BufReader, io::Cursor, sync::Arc};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_record_reader_removes_only_line_framing() {
|
||||||
|
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
|
||||||
|
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||||
|
Some(" first ".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||||
|
Some(String::new())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||||
|
Some("third\r".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
|
||||||
|
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
|
||||||
|
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
|
||||||
|
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||||
|
|
||||||
|
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert!(error.to_string().contains("maximum size"));
|
||||||
|
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
|
||||||
|
let mailbox = StdinMailbox::default();
|
||||||
|
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||||
|
let submitted = mailbox.latest().unwrap();
|
||||||
|
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||||
|
let pending = mailbox.latest().unwrap();
|
||||||
|
|
||||||
|
mailbox.clear_if_current(submitted.revision);
|
||||||
|
|
||||||
|
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
|
||||||
|
let mailbox = StdinMailbox::default();
|
||||||
|
mailbox.publish(Vec::new());
|
||||||
|
let target_set = mailbox.latest().unwrap();
|
||||||
|
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||||
|
id: TargetId::from("old"),
|
||||||
|
generation: 1,
|
||||||
|
}]));
|
||||||
|
let capacity_freed = Arc::new(status_with_targets(&[]));
|
||||||
|
// Capacity becomes available after the transaction rejected but before
|
||||||
|
// its acknowledgement reaches the controller. The rejection path must
|
||||||
|
// retain the pre-submission status, not this later snapshot.
|
||||||
|
let mut retry_after =
|
||||||
|
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
|
||||||
|
|
||||||
|
assert!(retry_waits_for_status_change(
|
||||||
|
&mut retry_after,
|
||||||
|
&target_set,
|
||||||
|
&saturated
|
||||||
|
));
|
||||||
|
assert!(!retry_waits_for_status_change(
|
||||||
|
&mut retry_after,
|
||||||
|
&target_set,
|
||||||
|
&capacity_freed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
|
||||||
|
let mailbox = StdinMailbox::default();
|
||||||
|
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||||
|
let applied = mailbox.latest().unwrap();
|
||||||
|
mailbox.clear_if_current(applied.revision);
|
||||||
|
|
||||||
|
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||||
|
let rejected = mailbox.latest().unwrap();
|
||||||
|
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||||
|
id: TargetId::from("old"),
|
||||||
|
generation: 1,
|
||||||
|
}]));
|
||||||
|
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
|
||||||
|
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
|
||||||
|
let replacement = mailbox.latest().unwrap();
|
||||||
|
|
||||||
|
assert!(!retry_waits_for_status_change(
|
||||||
|
&mut retry_after,
|
||||||
|
&replacement,
|
||||||
|
&Arc::new(status_with_targets(&[TargetInstance {
|
||||||
|
id: TargetId::from("old"),
|
||||||
|
generation: 1,
|
||||||
|
}]))
|
||||||
|
));
|
||||||
|
assert!(retry_after.is_none());
|
||||||
|
assert_eq!(replacement.targets[0].id.as_str(), "c");
|
||||||
|
mailbox.clear_if_current(replacement.revision);
|
||||||
|
assert!(mailbox.latest().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eof_and_shutdown_stop_without_waiting_for_the_stdin_reader() {
|
||||||
|
let mailbox = StdinMailbox::default();
|
||||||
|
let shutdown_requested = AtomicBool::new(false);
|
||||||
|
assert!(stdin_stop_request(&shutdown_requested, &mailbox).is_none());
|
||||||
|
|
||||||
|
mailbox.finish();
|
||||||
|
assert!(matches!(
|
||||||
|
stdin_stop_request(&shutdown_requested, &mailbox),
|
||||||
|
Some(StdinStop::Eof)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mailbox = StdinMailbox::default();
|
||||||
|
shutdown_requested.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert!(matches!(
|
||||||
|
stdin_stop_request(&shutdown_requested, &mailbox),
|
||||||
|
Some(StdinStop::Interrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
|
||||||
|
ManagedStatus {
|
||||||
|
lifecycle: ManagedLifecycle::Running,
|
||||||
|
stop_requested: false,
|
||||||
|
applied_command_sequence: 0,
|
||||||
|
desired_target_count: targets.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(
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|target| ManagedTargetStatus {
|
||||||
|
target,
|
||||||
|
desired: true,
|
||||||
|
lifecycle: ManagedTargetLifecycle::Active,
|
||||||
|
server_addr: Arc::from("127.0.0.1:2112"),
|
||||||
|
remote: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_boxed_slice(),
|
||||||
|
),
|
||||||
|
recent_target_outcomes: Arc::from([]),
|
||||||
|
final_outcome: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
|
||||||
|
let retired = TargetInstance {
|
||||||
|
id: TargetId::from("edge"),
|
||||||
|
generation: 1,
|
||||||
|
};
|
||||||
|
let replacement = TargetInstance {
|
||||||
|
id: TargetId::from("edge"),
|
||||||
|
generation: 2,
|
||||||
|
};
|
||||||
|
let live_status = status_with_targets(std::slice::from_ref(&replacement));
|
||||||
|
let empty_status = status_with_targets(&[]);
|
||||||
|
let mut stats = BTreeMap::from([
|
||||||
|
(retired.clone(), StatsCollector::new(stats_config(true))),
|
||||||
|
(replacement.clone(), StatsCollector::new(stats_config(true))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reconcile_stdin_stats(&mut stats, &live_status, None);
|
||||||
|
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
|
||||||
|
|
||||||
|
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
|
||||||
|
stats.insert(retired, StatsCollector::new(stats_config(true)));
|
||||||
|
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stats.keys().cloned().collect::<Vec<_>>(),
|
||||||
|
[TargetInstance {
|
||||||
|
id: TargetId::from("edge"),
|
||||||
|
generation: 2,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_deduplication_is_bounded_under_target_churn() {
|
||||||
|
let mut terminal_targets = BoundedTargetSet::new(2);
|
||||||
|
let target = |generation| TargetInstance {
|
||||||
|
id: TargetId::from("edge"),
|
||||||
|
generation,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(terminal_targets.insert(target(1)));
|
||||||
|
assert!(terminal_targets.insert(target(2)));
|
||||||
|
assert!(terminal_targets.insert(target(3)));
|
||||||
|
assert!(terminal_targets.insert(target(1)));
|
||||||
|
assert!(!terminal_targets.insert(target(3)));
|
||||||
|
}
|
||||||
|
|
||||||
fn long_finite_args() -> ClientArgs {
|
fn long_finite_args() -> ClientArgs {
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ pub struct ServerArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
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\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."
|
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>,
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ pub struct ServerArgs {
|
|||||||
long,
|
long,
|
||||||
value_name = "KEY",
|
value_name = "KEY",
|
||||||
value_parser = parse_hmac_key,
|
value_parser = parse_hmac_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."
|
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>,
|
||||||
|
|
||||||
@@ -40,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>,
|
||||||
@@ -48,6 +51,7 @@ 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>,
|
||||||
@@ -57,6 +61,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_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."
|
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>,
|
||||||
@@ -65,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>,
|
||||||
@@ -74,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>,
|
||||||
@@ -83,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>,
|
||||||
@@ -92,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,
|
||||||
}
|
}
|
||||||
@@ -241,11 +263,14 @@ mod tests {
|
|||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::cmd::server::env_lock::with_env_lock;
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||||
|
with_env_lock(|| {
|
||||||
let mut argv = vec!["irtt-server"];
|
let mut argv = vec!["irtt-server"];
|
||||||
argv.extend_from_slice(args);
|
argv.extend_from_slice(args);
|
||||||
ServerArgs::try_parse_from(argv)
|
ServerArgs::try_parse_from(argv)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bound(args: &[&str]) -> ServerArgs {
|
fn bound(args: &[&str]) -> ServerArgs {
|
||||||
@@ -479,4 +504,147 @@ mod tests {
|
|||||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs `body` with the given environment variables set, restoring
|
||||||
|
/// whatever was there before (present or absent) once `body` returns.
|
||||||
|
/// Held under `with_env_lock` for the whole span, including `body`, so a
|
||||||
|
/// `parse` call elsewhere never observes a partially-applied environment.
|
||||||
|
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
|
||||||
|
with_env_lock(|| {
|
||||||
|
let previous: Vec<(&str, Option<String>)> = vars
|
||||||
|
.iter()
|
||||||
|
.map(|(k, _)| (*k, std::env::var(k).ok()))
|
||||||
|
.collect();
|
||||||
|
for (k, v) in vars {
|
||||||
|
std::env::set_var(k, v);
|
||||||
|
}
|
||||||
|
let result = body();
|
||||||
|
for (k, v) in previous {
|
||||||
|
match v {
|
||||||
|
Some(v) => std::env::set_var(k, v),
|
||||||
|
None => std::env::remove_var(k),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_variables_supply_policy_when_no_flag_is_given() {
|
||||||
|
with_env(
|
||||||
|
&[
|
||||||
|
("IRTT_SERVER_MAX_SESSIONS", "512"),
|
||||||
|
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
|
||||||
|
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
|
||||||
|
("IRTT_SERVER_BURST", "3"),
|
||||||
|
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
|
||||||
|
("IRTT_SERVER_MAX_DURATION", "2m"),
|
||||||
|
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
|
||||||
|
("IRTT_SERVER_HMAC", "secret"),
|
||||||
|
("IRTT_SERVER_NO_DSCP", "true"),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||||
|
.unwrap()
|
||||||
|
.server_config();
|
||||||
|
assert_eq!(config.max_sessions(), 512);
|
||||||
|
assert_eq!(config.max_packet_length(), 1472);
|
||||||
|
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||||
|
assert_eq!(config.burst_allowance(), 3);
|
||||||
|
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||||
|
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||||
|
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||||
|
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||||
|
assert!(!config.dscp_allowed());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
|
||||||
|
/// boolean rather than a number, a duration or a string, and the
|
||||||
|
/// container/orchestrator deployments this whole environment surface
|
||||||
|
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
|
||||||
|
/// accepts only the literals `true`/`false` and turns everything else
|
||||||
|
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
|
||||||
|
/// server from starting at all rather than enabling the flag. Falsey
|
||||||
|
/// semantics are what keeps that from being a startup failure.
|
||||||
|
#[test]
|
||||||
|
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
|
||||||
|
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
|
||||||
|
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||||
|
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||||
|
.expect("a truthy value must not fail argument parsing")
|
||||||
|
.server_config();
|
||||||
|
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in ["0", "false", "no", "off", ""] {
|
||||||
|
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||||
|
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||||
|
.expect("a falsey value must not fail argument parsing")
|
||||||
|
.server_config();
|
||||||
|
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command-line flag stays a flag: it takes no value, whatever the
|
||||||
|
/// environment parser accepts.
|
||||||
|
#[test]
|
||||||
|
fn the_no_dscp_flag_still_takes_no_command_line_value() {
|
||||||
|
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
|
||||||
|
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_flag_overrides_the_environment() {
|
||||||
|
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
|
||||||
|
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||||
|
.unwrap()
|
||||||
|
.server_config();
|
||||||
|
assert_eq!(
|
||||||
|
config.max_sessions(),
|
||||||
|
4,
|
||||||
|
"the command-line value wins over the environment"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_environment_wins_over_the_built_in_default() {
|
||||||
|
with_env(&[("IRTT_SERVER_BURST", "9")], || {
|
||||||
|
let config = bound(&[]).server_config();
|
||||||
|
assert_eq!(
|
||||||
|
config.burst_allowance(),
|
||||||
|
9,
|
||||||
|
"an unset flag still falls back to the environment before the library default"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
|
||||||
|
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||||
|
let args = parse(&[]).unwrap();
|
||||||
|
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
|
||||||
|
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||||
|
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||||
|
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
|
||||||
|
let args = parse(&[]).unwrap();
|
||||||
|
assert!(
|
||||||
|
args.bind.is_empty(),
|
||||||
|
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! Serializes every test in this module tree that touches process
|
||||||
|
//! environment variables.
|
||||||
|
//!
|
||||||
|
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
|
||||||
|
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
|
||||||
|
//! Every test that parses `ServerArgs` — whether or not it sets an env var
|
||||||
|
//! itself — must go through [`with_env_lock`], or a parse in one test thread
|
||||||
|
//! can observe a value another thread's test set only for the duration of
|
||||||
|
//! its own assertions. Reentrant per-thread, since a test that sets env vars
|
||||||
|
//! typically calls `parse` again from inside its own locked section.
|
||||||
|
|
||||||
|
use std::{cell::Cell, sync::Mutex};
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
thread_local! {
|
||||||
|
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||||
|
if ENV_LOCK_HELD.with(Cell::get) {
|
||||||
|
return f();
|
||||||
|
}
|
||||||
|
let _guard = ENV_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
ENV_LOCK_HELD.with(|held| held.set(true));
|
||||||
|
let result = f();
|
||||||
|
ENV_LOCK_HELD.with(|held| held.set(false));
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod args;
|
pub mod args;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod env_lock;
|
||||||
pub mod run;
|
pub mod run;
|
||||||
|
|
||||||
pub use args::ServerArgs;
|
pub use args::ServerArgs;
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ mod tests {
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::cmd::server::env_lock::with_env_lock;
|
||||||
|
|
||||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||||
/// on the same orchestration path as every other.
|
/// on the same orchestration path as every other.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
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 args = with_env_lock(|| {
|
||||||
|
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
|
||||||
|
});
|
||||||
let shutdown_requested = AtomicBool::new(true);
|
let shutdown_requested = AtomicBool::new(true);
|
||||||
|
|
||||||
run_server(args, &shutdown_requested).unwrap();
|
run_server(args, &shutdown_requested).unwrap();
|
||||||
@@ -124,23 +127,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||||
let args = ServerArgs::try_parse_from([
|
let args = with_env_lock(|| {
|
||||||
|
ServerArgs::try_parse_from([
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
"--bind",
|
"--bind",
|
||||||
"127.0.0.1:0",
|
"127.0.0.1:0",
|
||||||
"--bind",
|
"--bind",
|
||||||
"127.0.0.1:0",
|
"127.0.0.1:0",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
});
|
||||||
let shutdown_requested = AtomicBool::new(true);
|
let shutdown_requested = AtomicBool::new(true);
|
||||||
|
|
||||||
run_server(args, &shutdown_requested).unwrap();
|
run_server(args, &shutdown_requested).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_binds() -> Vec<SocketAddr> {
|
fn default_binds() -> Vec<SocketAddr> {
|
||||||
|
with_env_lock(|| {
|
||||||
ServerArgs::try_parse_from(["irtt-server"])
|
ServerArgs::try_parse_from(["irtt-server"])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.resolve_binds()
|
.resolve_binds()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
|||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||||
pub struct TuiArgs {
|
pub struct TuiArgs {
|
||||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||||
#[arg(
|
#[arg(
|
||||||
value_name = "TARGET",
|
value_name = "TARGET",
|
||||||
num_args = 1..,
|
num_args = 1..,
|
||||||
required = true,
|
required = true,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||||
)]
|
)]
|
||||||
pub targets: Vec<TargetArg>,
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ impl TuiArgs {
|
|||||||
TargetSelection {
|
TargetSelection {
|
||||||
targets: &self.targets,
|
targets: &self.targets,
|
||||||
pacing: self.pacing,
|
pacing: self.pacing,
|
||||||
|
stdin_controlled: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,8 +108,7 @@ mod tests {
|
|||||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||||
assert_eq!(args.targets.len(), 1);
|
assert_eq!(args.targets.len(), 1);
|
||||||
assert_eq!(args.targets[0].label, None);
|
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
|
||||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||||
assert!(args.is_continuous());
|
assert!(args.is_continuous());
|
||||||
@@ -181,8 +181,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_labelled_target_syntax_is_rejected() {
|
fn invalid_labelled_target_syntax_is_rejected() {
|
||||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||||
assert!(parse(&["label="]).is_err());
|
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ pub use args::{
|
|||||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||||
};
|
};
|
||||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
||||||
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
|
pub use prepare::{
|
||||||
|
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||||
|
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
|
};
|
||||||
pub use session::is_shutdown_requested;
|
pub use session::is_shutdown_requested;
|
||||||
pub use targets::{
|
pub use targets::{
|
||||||
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||||
TargetSpec,
|
PreparedTarget, TargetArg, TargetSpec,
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,9 @@ use super::{
|
|||||||
|
|
||||||
/// Capacity of the lossy managed presentation event channel.
|
/// Capacity of the lossy managed presentation event channel.
|
||||||
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
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.
|
/// Target and pacing arguments common to the client applets.
|
||||||
///
|
///
|
||||||
@@ -31,6 +34,7 @@ pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
|||||||
pub struct TargetSelection<'a> {
|
pub struct TargetSelection<'a> {
|
||||||
pub targets: &'a [TargetArg],
|
pub targets: &'a [TargetArg],
|
||||||
pub pacing: GroupPacingArg,
|
pub pacing: GroupPacingArg,
|
||||||
|
pub stdin_controlled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything a managed run needs, prepared and validated together.
|
/// Everything a managed run needs, prepared and validated together.
|
||||||
@@ -46,6 +50,8 @@ pub struct ManagedRunSetup {
|
|||||||
pub client: ClientConfig,
|
pub client: ClientConfig,
|
||||||
/// Send coordination across active targets.
|
/// Send coordination across active targets.
|
||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
|
/// Whether this stream may replace its desired set from stdin.
|
||||||
|
pub stdin_controlled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedRunSetup {
|
impl ManagedRunSetup {
|
||||||
@@ -70,7 +76,7 @@ impl ManagedRunSetup {
|
|||||||
/// Managed driver configuration for this run.
|
/// Managed driver configuration for this run.
|
||||||
pub fn managed_config(&self) -> ManagedClientConfig {
|
pub fn managed_config(&self) -> ManagedClientConfig {
|
||||||
let target_count = self.target_count();
|
let target_count = self.target_count();
|
||||||
ManagedClientConfig {
|
let mut config = ManagedClientConfig {
|
||||||
client: self.client.clone(),
|
client: self.client.clone(),
|
||||||
pacing: self.pacing,
|
pacing: self.pacing,
|
||||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||||
@@ -78,7 +84,13 @@ impl ManagedRunSetup {
|
|||||||
outcome_history_limit: target_count,
|
outcome_history_limit: target_count,
|
||||||
max_live_target_generations: target_count,
|
max_live_target_generations: target_count,
|
||||||
..ManagedClientConfig::default()
|
..ManagedClientConfig::default()
|
||||||
|
};
|
||||||
|
if self.stdin_controlled {
|
||||||
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
|
||||||
|
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
|
||||||
}
|
}
|
||||||
|
config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +103,22 @@ pub fn prepare_managed_run(
|
|||||||
duration: Duration,
|
duration: Duration,
|
||||||
selection: TargetSelection<'_>,
|
selection: TargetSelection<'_>,
|
||||||
) -> Result<ManagedRunSetup, String> {
|
) -> Result<ManagedRunSetup, String> {
|
||||||
let specs = target_specs(selection.targets)?;
|
let specs = if selection.stdin_controlled {
|
||||||
|
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
|
||||||
|
return Err(format!(
|
||||||
|
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
super::targets::target_specs_with_empty(selection.targets, true)?
|
||||||
|
} else {
|
||||||
|
target_specs(selection.targets)?
|
||||||
|
};
|
||||||
let targets = prepare_managed_targets(specs)?;
|
let targets = prepare_managed_targets(specs)?;
|
||||||
Ok(ManagedRunSetup {
|
Ok(ManagedRunSetup {
|
||||||
targets,
|
targets,
|
||||||
client: common.to_client_config(duration),
|
client: common.to_client_config(duration),
|
||||||
pacing: selection.pacing.into(),
|
pacing: selection.pacing.into(),
|
||||||
|
stdin_controlled: selection.stdin_controlled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,17 +141,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn unlabeled(addr: &str) -> TargetArg {
|
fn unlabeled(addr: &str) -> TargetArg {
|
||||||
TargetArg {
|
TargetArg::new(addr)
|
||||||
label: None,
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||||
TargetArg {
|
TargetArg::new(format!("{label}={addr}"))
|
||||||
label: Some(label.to_owned()),
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare(
|
fn prepare(
|
||||||
@@ -137,7 +153,15 @@ mod tests {
|
|||||||
pacing: GroupPacingArg,
|
pacing: GroupPacingArg,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
) -> Result<ManagedRunSetup, String> {
|
) -> Result<ManagedRunSetup, String> {
|
||||||
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
|
prepare_managed_run(
|
||||||
|
&common(&[]),
|
||||||
|
duration,
|
||||||
|
TargetSelection {
|
||||||
|
targets,
|
||||||
|
pacing,
|
||||||
|
stdin_controlled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -212,6 +236,7 @@ mod tests {
|
|||||||
TargetSelection {
|
TargetSelection {
|
||||||
targets: &targets,
|
targets: &targets,
|
||||||
pacing: GroupPacingArg::Staggered,
|
pacing: GroupPacingArg::Staggered,
|
||||||
|
stdin_controlled: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,26 +1,99 @@
|
|||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, fmt};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
|
use irtt_client::{
|
||||||
|
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
|
||||||
|
ClientAuthConfig,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// 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 {
|
pub struct TargetArg {
|
||||||
pub label: Option<String>,
|
input: String,
|
||||||
pub addr: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
impl TargetArg {
|
||||||
|
pub fn new(input: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
input: input.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TargetArg {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("TargetArg(<redacted>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture one positional target without exposing it through Clap diagnostics.
|
||||||
|
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||||
|
Ok(TargetArg::new(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub enum TargetAuth {
|
||||||
|
Inherit,
|
||||||
|
Override(Vec<u8>),
|
||||||
|
Disable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TargetAuth {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Inherit => f.write_str("Inherit"),
|
||||||
|
Self::Override(_) => f.write_str("Override(<redacted>)"),
|
||||||
|
Self::Disable => f.write_str("Disable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
pub struct TargetSpec {
|
pub struct TargetSpec {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
|
pub auth: TargetAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
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 struct PreparedTarget {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub managed: ManagedTargetConfig,
|
pub managed: ManagedTargetConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for PreparedTarget {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("PreparedTarget")
|
||||||
|
.field("label", &self.label)
|
||||||
|
.field("server_addr", &self.managed.server_addr)
|
||||||
|
.field(
|
||||||
|
"auth",
|
||||||
|
&match &self.managed.auth {
|
||||||
|
None => TargetAuth::Inherit,
|
||||||
|
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
|
||||||
|
TargetAuth::Override(Vec::new())
|
||||||
|
}
|
||||||
|
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
pub enum GroupPacingArg {
|
pub enum GroupPacingArg {
|
||||||
Staggered,
|
Staggered,
|
||||||
@@ -36,191 +109,383 @@ impl From<GroupPacingArg> for ManagedPacing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
enum TargetParseError {
|
||||||
match input.split_once('=') {
|
EmptyLabel,
|
||||||
None => Ok(TargetArg {
|
EmptyAddress,
|
||||||
label: None,
|
InvalidEscape,
|
||||||
addr: input.to_owned(),
|
EmptyTarget,
|
||||||
}),
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == delimiter {
|
||||||
|
parts.push(&input[start..index]);
|
||||||
|
start = index + ch.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
parts.push(&input[start..]);
|
||||||
|
Ok(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == delimiter {
|
||||||
|
return Ok(Some(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(input: &str) -> Result<String, TargetParseError> {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut escaped = false;
|
||||||
|
for ch in input.chars() {
|
||||||
|
if escaped {
|
||||||
|
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else {
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, ch) in input.char_indices() {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if input[index..].starts_with("@hmac=") {
|
||||||
|
return Ok(Some(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return Err(TargetParseError::InvalidEscape);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_target_syntax(
|
||||||
|
input: &str,
|
||||||
|
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err(TargetParseError::EmptyTarget);
|
||||||
|
}
|
||||||
|
let (main, auth) = match hmac_modifier(input)? {
|
||||||
|
Some(index) => {
|
||||||
|
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||||
|
let auth = if value.is_empty() {
|
||||||
|
TargetAuth::Disable
|
||||||
|
} else {
|
||||||
|
TargetAuth::Override(value.into_bytes())
|
||||||
|
};
|
||||||
|
(&input[..index], auth)
|
||||||
|
}
|
||||||
|
None => (input, TargetAuth::Inherit),
|
||||||
|
};
|
||||||
|
let (label, address) = match first_unescaped(main, '=')? {
|
||||||
|
None => (None, unescape(main)?),
|
||||||
|
Some(index) => {
|
||||||
|
let label = unescape(&main[..index])?;
|
||||||
|
let address = unescape(&main[index + '='.len_utf8()..])?;
|
||||||
|
(Some(label), address)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if label.as_deref().is_some_and(str::is_empty) {
|
||||||
|
return Err(TargetParseError::EmptyLabel);
|
||||||
|
}
|
||||||
|
if address.is_empty() {
|
||||||
|
return Err(TargetParseError::EmptyAddress);
|
||||||
|
}
|
||||||
|
Ok((label, address, auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn target_specs_with_empty(
|
||||||
|
targets: &[TargetArg],
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
let mut specs = Vec::with_capacity(targets.len());
|
let mut specs = Vec::with_capacity(targets.len());
|
||||||
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
|
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
|
||||||
for target in targets {
|
for (index, target) in targets.iter().enumerate() {
|
||||||
let label = match &target.label {
|
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
|
||||||
Some(label) => label.clone(),
|
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
|
||||||
|
let label = match explicit_label {
|
||||||
|
Some(label) => label,
|
||||||
None => {
|
None => {
|
||||||
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
|
let count = unlabeled_counts.entry(addr.clone()).or_default();
|
||||||
*count += 1;
|
*count += 1;
|
||||||
if *count == 1 {
|
if *count == 1 {
|
||||||
target.addr.clone()
|
addr.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{}#{}", target.addr, *count)
|
format!("{}#{}", addr, *count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
specs.push(TargetSpec {
|
specs.push(TargetSpec { label, addr, auth });
|
||||||
label,
|
|
||||||
addr: target.addr.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if specs.is_empty() {
|
if specs.is_empty() && !allow_empty {
|
||||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut labels = HashSet::new();
|
let mut labels = HashSet::new();
|
||||||
for spec in &specs {
|
for spec in &specs {
|
||||||
if !labels.insert(spec.label.clone()) {
|
if !labels.insert(spec.label.clone()) {
|
||||||
return Err(format!("duplicate target label {:?}", spec.label));
|
return Err("duplicate target label".to_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(specs)
|
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> {
|
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||||
let mut targets = Vec::with_capacity(specs.len());
|
let mut targets = Vec::with_capacity(specs.len());
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
|
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||||
|
managed.auth = match spec.auth {
|
||||||
|
TargetAuth::Inherit => None,
|
||||||
|
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
|
||||||
|
hmac_key: Some(hmac_key),
|
||||||
|
}),
|
||||||
|
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
|
||||||
|
};
|
||||||
targets.push(PreparedTarget {
|
targets.push(PreparedTarget {
|
||||||
label: spec.label.clone(),
|
label: spec.label,
|
||||||
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
|
managed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(targets)
|
Ok(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse one complete stdin target set after its line terminator was removed.
|
||||||
|
///
|
||||||
|
/// Commas frame stdin elements only; positional target arguments do not use
|
||||||
|
/// this framing and may therefore contain raw commas.
|
||||||
|
pub fn parse_stdin_target_set(
|
||||||
|
record: &str,
|
||||||
|
maximum_targets: usize,
|
||||||
|
) -> Result<Vec<PreparedTarget>, String> {
|
||||||
|
if record == "[]" {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
|
||||||
|
if elements.len() > maximum_targets {
|
||||||
|
return Err(format!(
|
||||||
|
"target set exceeds the {maximum_targets}-target limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||||
|
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn unlabeled(addr: &str) -> TargetArg {
|
fn target(input: &str) -> TargetArg {
|
||||||
TargetArg {
|
TargetArg::new(input)
|
||||||
label: None,
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||||
TargetArg {
|
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||||
label: Some(label.to_owned()),
|
|
||||||
addr: addr.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_target_without_equals_is_unlabeled() {
|
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||||
let target = parse_target("host.example").unwrap();
|
let parsed = specs(&[
|
||||||
assert_eq!(target.label, None);
|
"host.example",
|
||||||
assert_eq!(target.addr, "host.example");
|
"host.example:2112",
|
||||||
|
"[::1]:2112",
|
||||||
let target = parse_target("host.example:2112").unwrap();
|
"eu=host.example",
|
||||||
assert_eq!(target.label, None);
|
])
|
||||||
assert_eq!(target.addr, "host.example:2112");
|
.unwrap();
|
||||||
|
assert_eq!(parsed[0].label, "host.example");
|
||||||
let target = parse_target("[::1]:2112").unwrap();
|
assert_eq!(parsed[1].label, "host.example:2112");
|
||||||
assert_eq!(target.label, None);
|
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||||
assert_eq!(target.addr, "[::1]:2112");
|
assert_eq!(parsed[3].label, "eu");
|
||||||
|
assert!(parsed
|
||||||
|
.iter()
|
||||||
|
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_target_with_equals_splits_on_first() {
|
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||||
let target = parse_target("eu=host.example").unwrap();
|
let parsed = specs(&[
|
||||||
assert_eq!(target.label, Some("eu".to_owned()));
|
"default=default.example",
|
||||||
assert_eq!(target.addr, "host.example");
|
"private=private.example@hmac=YWJjZA==",
|
||||||
|
"public=public.example@hmac=",
|
||||||
let target = parse_target("eu=host.example:2112").unwrap();
|
])
|
||||||
assert_eq!(target.label, Some("eu".to_owned()));
|
.unwrap();
|
||||||
assert_eq!(target.addr, "host.example:2112");
|
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||||
|
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||||
|
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_target_rejects_empty_label_or_address() {
|
fn target_delimiters_are_escaped_contextually() {
|
||||||
assert!(parse_target("=host.example").is_err());
|
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||||
assert!(parse_target("eu=").is_err());
|
assert_eq!(parsed[0].label, "eu=west");
|
||||||
|
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||||
|
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positional_commas_are_not_reserved() {
|
||||||
|
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||||
|
assert_eq!(parsed[0].addr, "host,region");
|
||||||
|
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||||
|
let secret = "very-secret-value";
|
||||||
|
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||||
|
assert!(!error.contains(secret));
|
||||||
|
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||||
|
assert!(
|
||||||
|
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
|
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||||
let specs = target_specs(&targets).unwrap();
|
assert_eq!(parsed[0].label, "host-a:2112");
|
||||||
|
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||||
assert_eq!(specs[0].label, "host-a:2112");
|
assert_eq!(
|
||||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||||
|
"duplicate target label"
|
||||||
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]
|
#[test]
|
||||||
fn target_specs_preserve_argument_order_with_mixed_labels() {
|
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||||
let targets = vec![
|
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||||
unlabeled("local"),
|
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||||
labeled("eu", "eu.example"),
|
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||||
unlabeled("backup"),
|
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||||
labeled("us", "us.example"),
|
assert_eq!(
|
||||||
];
|
addresses,
|
||||||
let specs = target_specs(&targets).unwrap();
|
["local", "eu.example", "foo", "us.example", "foo"]
|
||||||
|
);
|
||||||
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]
|
#[test]
|
||||||
fn target_specs_reject_generated_suffix_collisions() {
|
fn target_specs_reject_generated_suffix_collisions() {
|
||||||
let targets = vec![
|
assert_eq!(
|
||||||
unlabeled("host.example"),
|
specs(&[
|
||||||
unlabeled("host.example"),
|
"host.example",
|
||||||
labeled("host.example#2", "other.example"),
|
"host.example",
|
||||||
];
|
"host.example#2=other.example"
|
||||||
let err = target_specs(&targets).unwrap_err();
|
])
|
||||||
assert!(err.contains("duplicate target label"));
|
.unwrap_err(),
|
||||||
|
"duplicate target label"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
|
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||||
let specs = vec![
|
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||||
TargetSpec {
|
assert!(specs(&[input]).is_err(), "{input}");
|
||||||
label: "a".to_owned(),
|
}
|
||||||
addr: "example.test".to_owned(),
|
}
|
||||||
},
|
|
||||||
TargetSpec {
|
#[test]
|
||||||
label: "b".to_owned(),
|
fn target_preparation_maps_auth_to_managed_config() {
|
||||||
addr: "example.test".to_owned(),
|
let prepared =
|
||||||
},
|
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||||
];
|
assert_eq!(
|
||||||
let targets = prepare_managed_targets(specs).unwrap();
|
prepared[0]
|
||||||
assert_eq!(targets[0].managed.server_addr, "example.test");
|
.managed
|
||||||
assert_eq!(targets[1].managed.server_addr, "example.test");
|
.auth
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.hmac_key
|
||||||
|
.as_deref(),
|
||||||
|
Some(b"key".as_slice())
|
||||||
|
);
|
||||||
|
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_preparation_preserves_duplicate_endpoints() {
|
||||||
|
let prepared =
|
||||||
|
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||||
|
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||||
|
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||||
|
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||||
|
Some(b"x,y".as_slice())
|
||||||
|
);
|
||||||
|
assert_eq!(parsed[1].label, "b");
|
||||||
|
assert_eq!(
|
||||||
|
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||||
|
.managed
|
||||||
|
.server_addr,
|
||||||
|
" []"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||||
|
.managed
|
||||||
|
.server_addr,
|
||||||
|
"[] "
|
||||||
|
);
|
||||||
|
assert!(parse_stdin_target_set("", 128).is_err());
|
||||||
|
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||||
|
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,30 +26,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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
description = "IRTT-compatible Rust client library"
|
description = "IRTT-compatible Rust client library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -44,6 +44,8 @@ required-features = ["tokio"]
|
|||||||
doc-scrape-examples = true
|
doc-scrape-examples = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
irtt-server.workspace = true
|
# Keep the packaged client testable against the latest published server while
|
||||||
|
# workspace tests continue to exercise the local source through this path.
|
||||||
|
irtt-server = { version = "0.5.1", path = "../irtt-server" }
|
||||||
proptest = "1.11.0"
|
proptest = "1.11.0"
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||||
@@ -608,8 +608,14 @@ impl AsyncClient {
|
|||||||
// No sent_at measurement, no machine commit, no schedule
|
// No sent_at measurement, no machine commit, no schedule
|
||||||
// commit: the transaction is fully discarded and retried
|
// 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(ClientError::Socket(error)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
|
|||||||
@@ -163,12 +163,13 @@ impl Client {
|
|||||||
///
|
///
|
||||||
/// 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 [`ClientConfig::open_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
|
||||||
@@ -523,11 +524,18 @@ impl Client {
|
|||||||
.unwrap_or_else(Instant::now);
|
.unwrap_or_else(Instant::now);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if fail_send {
|
if fail_send {
|
||||||
|
runtime.invalidate_kernel_tx_correlation();
|
||||||
return Err(ClientError::Socket(io::Error::other(
|
return Err(ClientError::Socket(io::Error::other(
|
||||||
"injected probe send failure",
|
"injected probe send failure",
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let bytes = socket.send(&prepared.bytes)?;
|
let bytes = match socket.send(&prepared.bytes) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) => {
|
||||||
|
runtime.invalidate_kernel_tx_correlation();
|
||||||
|
return Err(ClientError::Socket(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
let send_finished_at = Instant::now();
|
let send_finished_at = Instant::now();
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -250,15 +250,11 @@ fn dynamic_update_reaches_background_worker() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let handle = owner.handle();
|
let handle = owner.handle();
|
||||||
|
|
||||||
let acknowledgement = test_runtime().block_on(async {
|
let acknowledgement = handle
|
||||||
timeout(
|
.update_targets(vec![target(&server)])
|
||||||
Duration::from_secs(2),
|
|
||||||
handle.update_targets(vec![target(&server)]).unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap()
|
.blocking_wait()
|
||||||
});
|
.unwrap();
|
||||||
assert_eq!(acknowledgement.sequence, 1);
|
assert_eq!(acknowledgement.sequence, 1);
|
||||||
server.wait_probe();
|
server.wait_probe();
|
||||||
test_runtime().block_on(async {
|
test_runtime().block_on(async {
|
||||||
|
|||||||
@@ -301,6 +301,19 @@ pub struct ManagedCommandReceipt {
|
|||||||
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
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 {
|
impl Future for ManagedCommandReceipt {
|
||||||
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
||||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||||
@@ -1600,6 +1613,30 @@ impl ManagedClientTask {
|
|||||||
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
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(
|
fn record_stagger_acceptance(
|
||||||
&mut self,
|
&mut self,
|
||||||
result: SendResult,
|
result: SendResult,
|
||||||
@@ -1613,7 +1650,7 @@ impl ManagedClientTask {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.last_stagger_send = Some(accepted_at);
|
self.last_stagger_send = Some(accepted_at);
|
||||||
self.send_gate = accepted_at.checked_add(spacing);
|
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if let Some(observations) = &self.stagger_observations {
|
if let Some(observations) = &self.stagger_observations {
|
||||||
observations.lock().unwrap().push((_active, spacing));
|
observations.lock().unwrap().push((_active, spacing));
|
||||||
|
|||||||
@@ -1576,6 +1576,37 @@ fn authenticated_peer_close_outcome() {
|
|||||||
assert!(!has_close(&records));
|
assert!(!has_close(&records));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_close_leaves_other_explicitly_managed_targets_running() {
|
||||||
|
let closing = start_server(ServerBehavior::PeerClose, None);
|
||||||
|
let continuing = start_server(ServerBehavior::Echo, None);
|
||||||
|
let mut managed = config(ManagedPacing::Staggered);
|
||||||
|
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
managed.client.duration = None;
|
||||||
|
let (task, handle) = ManagedClient::task(
|
||||||
|
managed,
|
||||||
|
vec![
|
||||||
|
target("closing", closing.addr),
|
||||||
|
target("continuing", continuing.addr),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let runtime = runtime();
|
||||||
|
let mut task = Box::pin(task);
|
||||||
|
|
||||||
|
drive_task_until(&runtime, &mut task, |task| {
|
||||||
|
handle.status().peer_closed_target_outcomes == 1
|
||||||
|
&& task.targets[1].counters.packets_sent != 0
|
||||||
|
});
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||||
|
|
||||||
|
let stop = handle.stop();
|
||||||
|
runtime.block_on(task);
|
||||||
|
runtime.block_on(stop);
|
||||||
|
closing.finish();
|
||||||
|
assert!(!probes(&continuing.finish()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_close_during_stop_drain_remains_authoritative() {
|
fn peer_close_during_stop_drain_remains_authoritative() {
|
||||||
let key = b"managed-drain-peer-close".to_vec();
|
let key = b"managed-drain-peer-close".to_vec();
|
||||||
@@ -1864,6 +1895,78 @@ fn stagger_gate_tracks_active_membership() {
|
|||||||
second.finish();
|
second.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stagger gate must not fold each send's observed wakeup latency into
|
||||||
|
/// every later slot.
|
||||||
|
///
|
||||||
|
/// The gate is the binding constraint on when the *group* may send next (see
|
||||||
|
/// `next_deadline`), while each target's own schedule stays on an absolute
|
||||||
|
/// grid anchored at its open. Re-anchoring the gate on every observed accept
|
||||||
|
/// time therefore made the whole group's cadence drift later without bound:
|
||||||
|
/// `EchoSent::timer_error` grew monotonically, and once the drift exceeded one
|
||||||
|
/// interval `skip_missed_probe_slots_at` started dropping probe slots
|
||||||
|
/// outright. Measured before this was fixed, a single-target 20 s run at a
|
||||||
|
/// 50 ms interval sent 381 probes instead of 400.
|
||||||
|
///
|
||||||
|
/// This asserts the invariant directly rather than through a real-time run,
|
||||||
|
/// because reproducing accumulation behaviorally needs seconds of wall clock
|
||||||
|
/// and a jitter assumption. Two properties, both durable:
|
||||||
|
///
|
||||||
|
/// - while the pacer keeps up, N accepted sends advance the gate by exactly
|
||||||
|
/// N spacings from the first anchor, whatever lateness each send is
|
||||||
|
/// observed with;
|
||||||
|
/// - a gate the pacer has fallen a full spacing behind re-anchors on the
|
||||||
|
/// accept time instead, so a stranded gate cannot release a burst of
|
||||||
|
/// catch-up sends.
|
||||||
|
#[test]
|
||||||
|
fn the_stagger_gate_advances_by_one_spacing_per_send_without_accumulating_lateness() {
|
||||||
|
// No socket is involved: the gate is driven directly and the task is never
|
||||||
|
// polled, so an unreachable address is all a valid target set needs here.
|
||||||
|
let (mut task, _) = ManagedClient::task(
|
||||||
|
config(ManagedPacing::Staggered),
|
||||||
|
vec![target("only", "127.0.0.1:9".parse().unwrap())],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let spacing = Duration::from_millis(50);
|
||||||
|
let start = Instant::now();
|
||||||
|
let lateness = Duration::from_millis(3);
|
||||||
|
|
||||||
|
task.record_stagger_acceptance(
|
||||||
|
SendResult::Ready { accepted: true },
|
||||||
|
Some((1, spacing)),
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
assert_eq!(task.send_gate, Some(start + spacing), "first send anchors");
|
||||||
|
|
||||||
|
// Every later send is observed `lateness` after the gate that released it.
|
||||||
|
// The gate must still advance one spacing per send from `start`, not
|
||||||
|
// `spacing + lateness`.
|
||||||
|
for slot in 1..=8_u32 {
|
||||||
|
let released_at = start + spacing * slot;
|
||||||
|
task.record_stagger_acceptance(
|
||||||
|
SendResult::Ready { accepted: true },
|
||||||
|
Some((1, spacing)),
|
||||||
|
released_at + lateness,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
task.send_gate,
|
||||||
|
Some(start + spacing * (slot + 1)),
|
||||||
|
"slot {slot} accumulated observed lateness into the gate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A full spacing behind is no longer the same cadence, so the gate
|
||||||
|
// re-anchors rather than staying stranded in the past.
|
||||||
|
let stalled_gate = task.send_gate.expect("the gate is set");
|
||||||
|
let resumed_at = stalled_gate + spacing;
|
||||||
|
task.record_stagger_acceptance(
|
||||||
|
SendResult::Ready { accepted: true },
|
||||||
|
Some((1, spacing)),
|
||||||
|
resumed_at,
|
||||||
|
);
|
||||||
|
assert_eq!(task.send_gate, Some(resumed_at + spacing));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
|
fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
|
||||||
let target_count = TIMEOUT_WORK_BUDGET + 1;
|
let target_count = TIMEOUT_WORK_BUDGET + 1;
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ pub(crate) struct PendingProbe {
|
|||||||
/// eligible only for upstream one-way delay after local plausibility
|
/// eligible only for upstream one-way delay after local plausibility
|
||||||
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
|
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
|
||||||
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
|
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
|
||||||
/// upstream one-way delay when this is absent or implausible.
|
/// upstream one-way delay when this is absent, implausible, or kernel-ID
|
||||||
|
/// correlation was invalidated by a failed probe submission.
|
||||||
pub kernel_tx_timestamp: Option<SystemTime>,
|
pub kernel_tx_timestamp: Option<SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,17 @@
|
|||||||
//! After a successful Open, the client adapter best-effort upgrades the
|
//! After a successful Open, the client adapter best-effort upgrades the
|
||||||
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
|
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
|
||||||
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
|
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
|
||||||
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
|
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
|
||||||
//! submitted datagram is used directly as the probe's wire sequence number
|
//! after successful sends, so it is used as a best-effort correlation ID.
|
||||||
//! (see the client crate's `AGENTS.md` for the correlation invariant this
|
//! A kernel ID can theoretically be consumed by a send that later fails,
|
||||||
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
|
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards
|
||||||
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
|
//! unmatched or implausible records; the userspace `sent_at` timestamp remains
|
||||||
//! timestamps without ever waiting for one. The observed kernel TX wall time
|
//! the fallback. After a probe send failure, correlation is disabled for that
|
||||||
//! is retained as metadata only; it is not consumed by any measurement in
|
//! session so later IDs cannot be misattributed. It performs a small bounded,
|
||||||
//! this change.
|
//! nonblocking read of
|
||||||
|
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
|
||||||
|
//! without ever waiting for one. The observed kernel TX wall time is retained
|
||||||
|
//! as metadata only; it is not consumed by any measurement in this change.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io::{self, IoSliceMut},
|
io::{self, IoSliceMut},
|
||||||
|
|||||||
@@ -624,22 +624,14 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not
|
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
|
||||||
// consume an automatic OPT_ID is not included here. Forcing a reliable,
|
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
|
||||||
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
|
// on a connected loopback UDP socket (for example through socket-buffer
|
||||||
// socket-buffer saturation) has no deterministic OS-level mechanism
|
// saturation) has no deterministic OS-level mechanism in this environment,
|
||||||
// available in this environment, and the repository's testing policy
|
// and the repository's testing policy forbids sleep/saturation-based
|
||||||
// explicitly forbids faking coverage with sleep/saturation-based
|
// flakiness. The correlation is intentionally best effort: a kernel ID can
|
||||||
// flakiness. That safety is instead established from Linux kernel
|
// theoretically be consumed by a send that later fails. The deterministic
|
||||||
// source: `_sock_tx_timestamp` (include/net/sock.h) and
|
// adapter test covers only the local half: an injected `WouldBlock` does
|
||||||
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
|
// not advance the local wire-sequence counter. Any later unmatched kernel
|
||||||
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before
|
// timestamp is discarded and the userspace `sent_at` remains the fallback.
|
||||||
// a datagram is fully built and explicitly `atomic_dec` it back out on
|
|
||||||
// every error path (tracked via a local `hold_tskey` flag), so a failed
|
|
||||||
// send can never leave a gap in — or otherwise advance — the ID space
|
|
||||||
// the kernel hands out to successfully submitted datagrams. See the
|
|
||||||
// client crate's `AGENTS.md` for the full citation. The client-side
|
|
||||||
// half of this invariant (a `WouldBlock` `try_send` never advances the
|
|
||||||
// local wire-sequence counter used as the correlation ID) is covered by
|
|
||||||
// a deterministic test in the adapter module that injects `WouldBlock`.
|
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,7 @@ struct ActiveSession {
|
|||||||
pending: PendingMap,
|
pending: PendingMap,
|
||||||
timed_out: TimedOutMap,
|
timed_out: TimedOutMap,
|
||||||
completed: CompletedSet,
|
completed: CompletedSet,
|
||||||
|
kernel_tx_correlation_valid: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -423,15 +424,27 @@ impl SessionMachine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disable Linux kernel TX timestamp correlation after a probe send fails.
|
||||||
|
///
|
||||||
|
/// A failed submission can consume a kernel `SOF_TIMESTAMPING_OPT_ID`
|
||||||
|
/// without advancing the wire sequence. Existing associated timestamps
|
||||||
|
/// remain valid observations; later error-queue timestamps cannot be
|
||||||
|
/// associated safely and are ignored for the rest of this session.
|
||||||
|
pub(crate) fn invalidate_kernel_tx_correlation(&mut self) {
|
||||||
|
if let MachineState::Open(session) = &mut self.state {
|
||||||
|
session.kernel_tx_correlation_valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the
|
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the
|
||||||
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
|
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
|
||||||
/// datagram was submitted.
|
/// datagram was submitted.
|
||||||
///
|
///
|
||||||
/// `wire_seq` doubles as that correlation ID: both counters start at
|
/// `wire_seq` normally matches that correlation ID after successful
|
||||||
/// zero for a session's first timestamped send and advance by exactly
|
/// sends. This is best effort rather than an invariant: a kernel ID can
|
||||||
/// one only on a confirmed successful submission, so they stay
|
/// theoretically be consumed by a send that later fails, leaving the
|
||||||
/// identical for the life of one open session (see the client crate's
|
/// counters desynchronized. An unmatched or implausible ID is discarded;
|
||||||
/// `AGENTS.md` for the full invariant).
|
/// the probe's userspace `sent_at` remains the fallback.
|
||||||
///
|
///
|
||||||
/// Updates a still-pending or already-timed-out probe in place. Never
|
/// Updates a still-pending or already-timed-out probe in place. Never
|
||||||
/// resurrects a completed, evicted, or unknown probe, and never touches
|
/// resurrects a completed, evicted, or unknown probe, and never touches
|
||||||
@@ -443,6 +456,9 @@ impl SessionMachine {
|
|||||||
let MachineState::Open(session) = &mut self.state else {
|
let MachineState::Open(session) = &mut self.state else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if !session.kernel_tx_correlation_valid {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(probe) = session.pending.get_mut(wire_seq) {
|
if let Some(probe) = session.pending.get_mut(wire_seq) {
|
||||||
probe.kernel_tx_timestamp.get_or_insert(timestamp);
|
probe.kernel_tx_timestamp.get_or_insert(timestamp);
|
||||||
return;
|
return;
|
||||||
@@ -636,6 +652,7 @@ impl SessionMachine {
|
|||||||
pending: PendingMap::new(self.config.max_pending_probes),
|
pending: PendingMap::new(self.config.max_pending_probes),
|
||||||
timed_out: TimedOutMap::new(self.config.max_pending_probes),
|
timed_out: TimedOutMap::new(self.config.max_pending_probes),
|
||||||
completed: CompletedSet::new(self.config.max_pending_probes),
|
completed: CompletedSet::new(self.config.max_pending_probes),
|
||||||
|
kernel_tx_correlation_valid: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let event = ClientEvent::SessionStarted {
|
let event = ClientEvent::SessionStarted {
|
||||||
@@ -1231,6 +1248,7 @@ mod tests {
|
|||||||
pending: PendingMap::new(max_pending_probes),
|
pending: PendingMap::new(max_pending_probes),
|
||||||
timed_out: TimedOutMap::new(max_pending_probes),
|
timed_out: TimedOutMap::new(max_pending_probes),
|
||||||
completed: CompletedSet::new(max_pending_probes),
|
completed: CompletedSet::new(max_pending_probes),
|
||||||
|
kernel_tx_correlation_valid: true,
|
||||||
}));
|
}));
|
||||||
machine
|
machine
|
||||||
}
|
}
|
||||||
@@ -2873,6 +2891,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalidated_kernel_tx_correlation_uses_userspace_send_time() {
|
||||||
|
let mono = Instant::now();
|
||||||
|
let mut machine = upstream_probe(send_wall_ms(1_000), mono);
|
||||||
|
|
||||||
|
// A failed send can consume a kernel ID without advancing wire_seq.
|
||||||
|
// A later timestamp whose ID happens to match a pending wire sequence
|
||||||
|
// must not be associated after that gap.
|
||||||
|
machine.invalidate_kernel_tx_correlation();
|
||||||
|
machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005));
|
||||||
|
|
||||||
|
let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reply_one_way(&events).unwrap().client_to_server,
|
||||||
|
Some(SignedDuration::from_nanos(20_000_000))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upstream_one_way_delay_falls_back_without_kernel_tx() {
|
fn upstream_one_way_delay_falls_back_without_kernel_tx() {
|
||||||
let mono = Instant::now();
|
let mono = Instant::now();
|
||||||
@@ -3195,6 +3232,7 @@ mod tests {
|
|||||||
pending: PendingMap::new(MAX_PENDING_PROBES),
|
pending: PendingMap::new(MAX_PENDING_PROBES),
|
||||||
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
|
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
|
||||||
completed: CompletedSet::new(MAX_PENDING_PROBES),
|
completed: CompletedSet::new(MAX_PENDING_PROBES),
|
||||||
|
kernel_tx_correlation_valid: true,
|
||||||
}));
|
}));
|
||||||
(machine, params)
|
(machine, params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,13 +500,14 @@ session policy lives.
|
|||||||
unmarked reply into a *dropped* one, because an unappliable marking stops the
|
unmarked reply into a *dropped* one, because an unappliable marking stops the
|
||||||
send. Verify a platform before extending that list.
|
send. Verify a platform before extending that list.
|
||||||
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
|
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
|
||||||
replies, and this is known. It is not fixed by choosing the option per reply,
|
replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers
|
||||||
for the macOS reason above, and it predates wildcard source selection. Leave it
|
matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT`
|
||||||
until it is worth its own slice, and do not "fix" it by weakening the rule that
|
listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that
|
||||||
an unappliable marking stops the send. The crate forbids unsafe code;
|
pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not
|
||||||
there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the
|
weaken the rule that an unappliable marking stops the send. The crate forbids
|
||||||
target lists identical to the pinned `socket2` version's own gates — check
|
unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery.
|
||||||
its source rather than copying another crate's older matrix.
|
Keep the target lists identical to the pinned `socket2` version's own gates
|
||||||
|
— check its source rather than copying another crate's older matrix.
|
||||||
|
|
||||||
## Rate, lifetime and server-initiated close
|
## Rate, lifetime and server-initiated close
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-server"
|
name = "irtt-server"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
description = "IRTT-compatible Rust server library"
|
description = "IRTT-compatible Rust server library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ fn parse_hex_pattern(body: &str) -> Option<Vec<u8>> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
digits
|
digits
|
||||||
.chunks_exact(2)
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
.map(|pair| Some(hex_digit(pair[0])? << 4 | hex_digit(pair[1])?))
|
.map(|pair| Some(hex_digit(pair[0])? << 4 | hex_digit(pair[1])?))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ impl ServerSet {
|
|||||||
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
|
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
|
||||||
/// here, not an IPv6 one, matching how the runtime already reads it.
|
/// here, not an IPv6 one, matching how the runtime already reads it.
|
||||||
///
|
///
|
||||||
|
/// A lone explicit dual-stack `[::]:PORT` listener is not the recommended
|
||||||
|
/// configuration when reply DSCP/traffic-class marking for IPv4 peers
|
||||||
|
/// matters: Linux serves those peers as IPv4-mapped and cannot retain the
|
||||||
|
/// negotiated marking on their replies. Request the normal same-port
|
||||||
|
/// `[::]:PORT` and `0.0.0.0:PORT` pair instead. This method makes the IPv6
|
||||||
|
/// half IPv6-only for that pair only; a lone IPv6 listener retains its
|
||||||
|
/// normal socket behavior.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
|
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
|
||||||
|
|||||||
@@ -139,5 +139,5 @@ scenario contains the existing comparison harness output under
|
|||||||
`netem-summary.json` files. Server stdout/stderr are captured at the run root as
|
`netem-summary.json` files. Server stdout/stderr are captured at the run root as
|
||||||
`server.stdout` and `server.stderr`.
|
`server.stdout` and `server.stderr`.
|
||||||
|
|
||||||
On macOS, `ip netns` and `tc netem` are unavailable; use the GitHub Actions
|
On macOS, `ip netns` and `tc netem` are unavailable; use the Gitea Actions
|
||||||
workflow for netem coverage.
|
workflow for netem coverage.
|
||||||
+85
-7
@@ -6,7 +6,7 @@ irtt-client - IRTT-compatible stream client
|
|||||||
|
|
||||||
## SYNOPSIS
|
## SYNOPSIS
|
||||||
|
|
||||||
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]...
|
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
|
||||||
|
|
||||||
`irtt-client` `--list-columns`
|
`irtt-client` `--list-columns`
|
||||||
|
|
||||||
@@ -18,17 +18,39 @@ eligible runs.
|
|||||||
|
|
||||||
## TARGETS
|
## TARGETS
|
||||||
|
|
||||||
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix
|
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
|
||||||
assigns the logical target name used in output; without it, the target
|
`LABEL=` prefix assigns the logical target name used in output; without it,
|
||||||
string itself is used as the label. Labeled and unlabeled targets can be
|
the target string itself is used as the label. Labeled and unlabeled targets
|
||||||
freely mixed in one argument list.
|
can be freely mixed in one argument list. Prefer explicit labels for
|
||||||
|
long-running dynamic target sets: the label is the stable target identity
|
||||||
|
across replacements.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
irtt-client host-a:2112
|
irtt-client host-a:2112
|
||||||
irtt-client eu=host-a:2112 us=host-b:2112
|
irtt-client eu=host-a:2112 us=host-b:2112
|
||||||
irtt-client host-a:2112 eu=host-b:2112
|
irtt-client host-a:2112 eu=host-b:2112
|
||||||
|
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
|
||||||
|
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
|
||||||
|
target. The modifier uses `@`, not a shell command separator; quote an argument
|
||||||
|
if its key needs shell quoting.
|
||||||
|
|
||||||
|
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
||||||
|
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||||
|
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||||
|
label or address when it would otherwise begin `@hmac=`; escaping the first
|
||||||
|
`=` is how an unlabeled target or a label can contain that character. An `=` in
|
||||||
|
an HMAC value, including Base64 padding, does not need escaping. Positional
|
||||||
|
target arguments may contain a raw comma; stdin target sets have the additional
|
||||||
|
`\,` escape described below.
|
||||||
|
|
||||||
|
Shells process unquoted backslashes and characters such as semicolons before
|
||||||
|
the client sees them. Single-quote a target when it contains parser escapes,
|
||||||
|
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
|
||||||
|
backslashes reach `irtt-client` unchanged.
|
||||||
|
|
||||||
Multiple targets are probed concurrently. By default, a multi-target session
|
Multiple targets are probed concurrently. By default, a multi-target session
|
||||||
uses **staggered** pacing, spacing active targets across the probe interval;
|
uses **staggered** pacing, spacing active targets across the probe interval;
|
||||||
pass `--pacing burst` to send one probe to every active target back-to-back
|
pass `--pacing burst` to send one probe to every active target back-to-back
|
||||||
@@ -36,6 +58,57 @@ once per interval instead. Final per-target summaries print in the order
|
|||||||
targets were supplied on the command line, not the order in which they
|
targets were supplied on the command line, not the order in which they
|
||||||
finish or their labels sort alphabetically.
|
finish or their labels sort alphabetically.
|
||||||
|
|
||||||
|
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||||
|
|
||||||
|
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||||
|
Positional targets remain an initial desired set, and may be omitted to
|
||||||
|
start empty:
|
||||||
|
|
||||||
|
~~~sh
|
||||||
|
irtt-client --duration 0 --targets-stdin
|
||||||
|
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Each non-empty stdin record declares the complete desired target set, rather
|
||||||
|
than a delta. A later record replaces an earlier set: targets absent from it
|
||||||
|
are retired, unchanged target configurations retain their generation, and a
|
||||||
|
changed address or HMAC setting creates a fresh generation. Under transient
|
||||||
|
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||||
|
|
||||||
|
Commas frame targets in stdin records; escape a literal comma within one
|
||||||
|
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||||
|
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||||
|
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
|
||||||
|
client is being controlled. For example, in one terminal:
|
||||||
|
|
||||||
|
~~~sh
|
||||||
|
mkfifo /tmp/irtt-targets
|
||||||
|
irtt-client --duration 0 --targets-stdin --hmac default-secret \
|
||||||
|
< /tmp/irtt-targets
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Then open the FIFO once from another terminal and keep that descriptor open
|
||||||
|
while publishing complete desired sets:
|
||||||
|
|
||||||
|
~~~sh
|
||||||
|
exec 3> /tmp/irtt-targets
|
||||||
|
printf '%s\n' 'ams=ams.example:2112,sg=sg.example:2112@hmac=sg-secret' >&3
|
||||||
|
printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
|
||||||
|
# Publish more sets as needed, then send EOF and stop the client:
|
||||||
|
exec 3>&-
|
||||||
|
~~~
|
||||||
|
|
||||||
|
The exact record `[]` selects an empty desired set. Empty records are
|
||||||
|
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
|
||||||
|
or closure of the control plane and requests a graceful stop. A desired-state
|
||||||
|
revision that was read but has not yet been acknowledged as applied may be
|
||||||
|
discarded when EOF is observed; EOF does not wait for a capacity-rejected
|
||||||
|
revision to become applicable.
|
||||||
|
|
||||||
|
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||||
|
desired set to 128 targets and retains at most 256 live target generations
|
||||||
|
while replacements drain.
|
||||||
|
|
||||||
## FINITE VERSUS CONTINUOUS OPERATION
|
## FINITE VERSUS CONTINUOUS OPERATION
|
||||||
|
|
||||||
`--duration` controls the run length:
|
`--duration` controls the run length:
|
||||||
@@ -45,8 +118,9 @@ finish or their labels sort alphabetically.
|
|||||||
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
|
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
|
||||||
- `--duration 0` is **continuous**: the client runs until interrupted
|
- `--duration 0` is **continuous**: the client runs until interrupted
|
||||||
(`Ctrl-C`). For table output, a final summary is printed only if the run
|
(`Ctrl-C`). For table output, a final summary is printed only if the run
|
||||||
was interrupted; an uninterrupted continuous run (one that exits for
|
was interrupted. Stdin-controlled mode is the exception: EOF also requests
|
||||||
another reason) does not get one.
|
a graceful stop and prints the final retained summary. An uninterrupted
|
||||||
|
continuous run that exits for another reason does not get one.
|
||||||
|
|
||||||
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
||||||
distinction also determines what statistics can be computed and how much
|
distinction also determines what statistics can be computed and how much
|
||||||
@@ -192,6 +266,10 @@ the client exits nonzero — so a process supervisor can restart it; an
|
|||||||
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
||||||
mode it was in.
|
mode it was in.
|
||||||
|
|
||||||
|
With `--targets-stdin`, peer closure is target-local: other desired targets
|
||||||
|
and the stdin controller continue running. Including that target in a later
|
||||||
|
complete set starts a fresh generation.
|
||||||
|
|
||||||
## EXIT STATUS
|
## EXIT STATUS
|
||||||
|
|
||||||
`0` on success (including a finite run's ordinary completion, and an
|
`0` on success (including a finite run's ordinary completion, and an
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
|
|||||||
what it is invoked or copied as — it does not consult argv0 dispatch logic.
|
what it is invoked or copied as — it does not consult argv0 dispatch logic.
|
||||||
|
|
||||||
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
|
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
|
||||||
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
|
one of the recognized applet names — `irtt-client`, `irtt-tui`, or
|
||||||
`irtt-server` — it runs that applet directly, without needing a subcommand.
|
`irtt-server` — it runs that applet directly, without needing a subcommand.
|
||||||
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
|
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
|
||||||
rejected as an unknown applet name before subcommand parsing is even
|
rejected as an unknown applet name before subcommand parsing is even
|
||||||
|
|||||||
+40
-1
@@ -60,7 +60,17 @@ served.
|
|||||||
A port of `0` selects an unused port per listener, so two such binds get two
|
A port of `0` selects an unused port per listener, so two such binds get two
|
||||||
different ports.
|
different ports.
|
||||||
|
|
||||||
With no `--bind` at all, see [Default bind](#default-bind) above.
|
With no `--bind` at all, see [Description](#description) above for the
|
||||||
|
default wildcard pair and the address-family fallback it allows.
|
||||||
|
|
||||||
|
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
|
||||||
|
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
|
||||||
|
served through that socket are IPv4-mapped and their replies cannot retain the
|
||||||
|
negotiated marking. Configure the ordinary paired `[::]:PORT` and
|
||||||
|
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
|
||||||
|
same-port pair, so each family uses the correct socket option. This is a
|
||||||
|
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
|
||||||
|
IPv6 listener.
|
||||||
|
|
||||||
### `--hmac KEY`
|
### `--hmac KEY`
|
||||||
|
|
||||||
@@ -136,6 +146,35 @@ and they are sent unmarked. The session is not refused.
|
|||||||
Durations everywhere in this command accept an integer followed by a unit:
|
Durations everywhere in this command accept an integer followed by a unit:
|
||||||
`ms`, `s`, or `m`.
|
`ms`, `s`, or `m`.
|
||||||
|
|
||||||
|
## ENVIRONMENT
|
||||||
|
|
||||||
|
Every option above can also be supplied through an environment variable, for
|
||||||
|
deployments that configure by environment rather than by command line
|
||||||
|
(containers, orchestrators, unit files). A flag given on the command line
|
||||||
|
overrides the corresponding variable entirely; an unset variable leaves the
|
||||||
|
built-in default in place.
|
||||||
|
|
||||||
|
| Variable | Option |
|
||||||
|
| --- | --- |
|
||||||
|
| `IRTT_SERVER_BIND` | `--bind`, one address only |
|
||||||
|
| `IRTT_SERVER_HMAC` | `--hmac` |
|
||||||
|
| `IRTT_SERVER_MAX_SESSIONS` | `--max-sessions` |
|
||||||
|
| `IRTT_SERVER_MAX_PACKET_LENGTH` | `--max-packet-length` |
|
||||||
|
| `IRTT_SERVER_MIN_INTERVAL` | `--min-interval` |
|
||||||
|
| `IRTT_SERVER_BURST` | `--burst` |
|
||||||
|
| `IRTT_SERVER_IDLE_TIMEOUT` | `--idle-timeout` |
|
||||||
|
| `IRTT_SERVER_MAX_DURATION` | `--max-duration` |
|
||||||
|
| `IRTT_SERVER_TIMESTAMP_ALLOWANCE` | `--timestamp-allowance` |
|
||||||
|
| `IRTT_SERVER_NO_DSCP` | `--no-dscp` |
|
||||||
|
|
||||||
|
`IRTT_SERVER_BIND` takes exactly one address, the same as a single `--bind`;
|
||||||
|
there is no way to request several listeners from the environment. Serving
|
||||||
|
more than one address needs repeated `--bind` options.
|
||||||
|
|
||||||
|
`IRTT_SERVER_NO_DSCP` is the only boolean. `0`, `false`, `no`, `n`, `off` and
|
||||||
|
an empty value leave traffic-class marking enabled; any other value disables
|
||||||
|
it, and an unset variable leaves it enabled.
|
||||||
|
|
||||||
## OUTPUT
|
## OUTPUT
|
||||||
|
|
||||||
Each bound endpoint is printed on startup, once every listener is up, which
|
Each bound endpoint is printed on startup, once every listener is up, which
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ presentation bounds on top of the client's own statistics retention (see
|
|||||||
total memory is dominated by whichever of the two is larger for your target
|
total memory is dominated by whichever of the two is larger for your target
|
||||||
count.
|
count.
|
||||||
|
|
||||||
|
A **finite** `--duration` TUI run retains exact statistics samples exactly as
|
||||||
|
`irtt-client(1)` does in finite mode, so its retained state grows with the
|
||||||
|
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
|
||||||
|
prints **no** memory warning when that estimate gets large: it owns the
|
||||||
|
alternate screen, and a stderr warning there would be invisible or would
|
||||||
|
corrupt the display. Size a long finite TUI run from the figures in
|
||||||
|
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
|
||||||
|
|
||||||
## EXIT BEHAVIOR
|
## EXIT BEHAVIOR
|
||||||
|
|
||||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
||||||
|
|||||||
Generated
+131
-4
@@ -8,6 +8,18 @@ version = "1.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -19,9 +31,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.3"
|
version = "1.4.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -35,6 +47,12 @@ 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 = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmov"
|
name = "cmov"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -126,10 +144,24 @@ dependencies = [
|
|||||||
name = "irtt-proto-fuzz"
|
name = "irtt-proto-fuzz"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
|
"irtt-server",
|
||||||
"libfuzzer-sys",
|
"libfuzzer-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "irtt-server"
|
||||||
|
version = "0.5.2"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"irtt-proto",
|
||||||
|
"nix",
|
||||||
|
"socket2",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jobserver"
|
name = "jobserver"
|
||||||
version = "0.1.35"
|
version = "0.1.35"
|
||||||
@@ -166,6 +198,45 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -196,6 +267,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -204,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.3"
|
version = "3.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -233,6 +314,31 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -244,3 +350,24 @@ name = "unicode-ident"
|
|||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
+12
-1
@@ -8,11 +8,15 @@ edition = "2021"
|
|||||||
cargo-fuzz = true
|
cargo-fuzz = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfuzzer-sys = "0.4"
|
arbitrary = "1.4.2"
|
||||||
|
libfuzzer-sys = "0.4.13"
|
||||||
|
|
||||||
[dependencies.irtt-proto]
|
[dependencies.irtt-proto]
|
||||||
path = "../crates/irtt-proto"
|
path = "../crates/irtt-proto"
|
||||||
|
|
||||||
|
[dependencies.irtt-server]
|
||||||
|
path = "../crates/irtt-server"
|
||||||
|
|
||||||
# Use independent workspace for fuzzers
|
# Use independent workspace for fuzzers
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["."]
|
members = ["."]
|
||||||
@@ -51,3 +55,10 @@ path = "fuzz_targets/decode_varint.rs"
|
|||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
bench = false
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "server_core_datagrams"
|
||||||
|
path = "fuzz_targets/server_core_datagrams.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
+9
-6
@@ -1,6 +1,7 @@
|
|||||||
# `irtt-proto` fuzzing
|
# `irtt-rs` fuzzing
|
||||||
|
|
||||||
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
|
Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public
|
||||||
|
`ServerCore::handle_datagram` API via
|
||||||
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
|
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
|
||||||
|
|
||||||
This project is intentionally isolated from the normal stable/MSRV
|
This project is intentionally isolated from the normal stable/MSRV
|
||||||
@@ -22,14 +23,16 @@ of the root workspace, requires nightly Rust, and is not built by
|
|||||||
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
|
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
|
||||||
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
|
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
|
||||||
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
|
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
|
||||||
|
| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap |
|
||||||
|
|
||||||
The invariant every target asserts is: **arbitrary bytes must never panic**.
|
The invariant every target asserts is: **arbitrary bytes must never panic**.
|
||||||
Returning `Err` is normal and expected; a successful decode is also normal.
|
Returning `Err` is normal and expected; a successful decode is also normal.
|
||||||
|
|
||||||
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
|
Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer
|
||||||
libFuzzer spends its time exploring protocol structure rather than
|
spends its time exploring protocol structure rather than allocating/copying
|
||||||
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
|
megabytes. `server_core_datagrams` additionally caps its sequence length and
|
||||||
under this bound.
|
each individual datagram and configures the core to the same packet cap, so one
|
||||||
|
libFuzzer input cannot become a tiny DoS benchmark.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use arbitrary::Unstructured;
|
||||||
|
use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
|
||||||
|
use irtt_server::{ServerConfig, ServerCore};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
/// Bounds one libFuzzer input as well as the work it can ask the core to do.
|
||||||
|
/// These are deliberately much smaller than a production UDP payload: the
|
||||||
|
/// target is stateful parser/session exploration, not packet-size throughput.
|
||||||
|
const MAX_INPUT_LEN: usize = 32 * 1024;
|
||||||
|
const MAX_DATAGRAMS: usize = 16;
|
||||||
|
const MAX_DATAGRAM_LEN: usize = 2 * 1024;
|
||||||
|
const MAX_SESSIONS: u8 = 8;
|
||||||
|
const MAX_HMAC_KEY_LEN: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FuzzSession {
|
||||||
|
token: u64,
|
||||||
|
peer: SocketAddr,
|
||||||
|
params: Params,
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if data.len() > MAX_INPUT_LEN {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut input = Unstructured::new(data);
|
||||||
|
let Ok(mut config) = config(&mut input) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let hmac_key = match input.arbitrary::<bool>() {
|
||||||
|
Ok(true) => {
|
||||||
|
let Ok(length) = input.int_in_range(1..=MAX_HMAC_KEY_LEN) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
input.bytes(length).ok()
|
||||||
|
}
|
||||||
|
Ok(false) => None,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if let Some(key) = hmac_key {
|
||||||
|
config = config.with_hmac_key(key);
|
||||||
|
}
|
||||||
|
let mut core = ServerCore::new(config);
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..MAX_DATAGRAMS {
|
||||||
|
let Ok(operation) = input.arbitrary::<u8>() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
match operation % 4 {
|
||||||
|
// Keep arbitrary-byte admission coverage alongside the stateful
|
||||||
|
// production-encoded requests below.
|
||||||
|
0 => raw_datagram(&mut core, &mut input),
|
||||||
|
1 => open_session(&mut core, &mut input, hmac_key, &mut sessions),
|
||||||
|
2 => echo_session(&mut core, &mut input, hmac_key, &sessions),
|
||||||
|
_ => close_session(&mut core, &mut input, hmac_key, &mut sessions),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arbitrary network input is expected to be rejected or to produce an
|
||||||
|
// internal error in exceptional host conditions. It must never panic,
|
||||||
|
// and it may never grow the bounded table past its configured cap.
|
||||||
|
assert!(core.session_count() <= core.config().max_sessions());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fn config(input: &mut Unstructured<'_>) -> arbitrary::Result<ServerConfig> {
|
||||||
|
let max_sessions = usize::from(input.int_in_range(0..=MAX_SESSIONS)?);
|
||||||
|
Ok(ServerConfig::default()
|
||||||
|
.with_max_sessions(max_sessions)
|
||||||
|
.with_max_packet_length(MAX_DATAGRAM_LEN)
|
||||||
|
.with_min_send_interval(Duration::ZERO))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_datagram(core: &mut ServerCore, input: &mut Unstructured<'_>) {
|
||||||
|
let (Ok(peer), Ok(length)) = (peer(input), input.int_in_range(0..=MAX_DATAGRAM_LEN)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(packet) = input.bytes(length) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = core.handle_datagram(peer, packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_session(
|
||||||
|
core: &mut ServerCore,
|
||||||
|
input: &mut Unstructured<'_>,
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
sessions: &mut Vec<FuzzSession>,
|
||||||
|
) {
|
||||||
|
let Ok(peer) = peer(input) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let params = session_params();
|
||||||
|
let request = encode_request(
|
||||||
|
RequestToEncode::Open {
|
||||||
|
params: ¶ms,
|
||||||
|
no_test: false,
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.expect("fixed valid fuzz open must encode");
|
||||||
|
let Ok(Some(reply)) = core.handle_datagram(peer, &request) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(reply) = decode_open_reply(reply.bytes(), hmac_key) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
sessions.push(FuzzSession {
|
||||||
|
token: reply.token,
|
||||||
|
peer,
|
||||||
|
params: reply.params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_session(
|
||||||
|
core: &mut ServerCore,
|
||||||
|
input: &mut Unstructured<'_>,
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
sessions: &[FuzzSession],
|
||||||
|
) {
|
||||||
|
let Some(session) =
|
||||||
|
sessions.get(usize::from(input.arbitrary::<u8>().unwrap_or(0)) % sessions.len().max(1))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sequence = input.arbitrary().unwrap_or_default();
|
||||||
|
let request = encode_request(
|
||||||
|
RequestToEncode::Echo {
|
||||||
|
token: session.token,
|
||||||
|
sequence,
|
||||||
|
params: &session.params,
|
||||||
|
payload: &[],
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.expect("fixed valid fuzz echo must encode");
|
||||||
|
let _ = core.handle_datagram(session.peer, &request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_session(
|
||||||
|
core: &mut ServerCore,
|
||||||
|
input: &mut Unstructured<'_>,
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
sessions: &mut Vec<FuzzSession>,
|
||||||
|
) {
|
||||||
|
let index = usize::from(input.arbitrary::<u8>().unwrap_or(0)) % sessions.len().max(1);
|
||||||
|
let Some(session) = sessions.get(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let request = encode_request(
|
||||||
|
RequestToEncode::Close {
|
||||||
|
token: session.token,
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.expect("fixed valid fuzz close must encode");
|
||||||
|
let _ = core.handle_datagram(session.peer, &request);
|
||||||
|
sessions.swap_remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_params() -> Params {
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
length: 256,
|
||||||
|
..Params::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(input: &mut Unstructured<'_>) -> arbitrary::Result<SocketAddr> {
|
||||||
|
let port = input.arbitrary()?;
|
||||||
|
let address = if input.arbitrary()? {
|
||||||
|
IpAddr::V4(Ipv4Addr::from(input.arbitrary::<[u8; 4]>()?))
|
||||||
|
} else {
|
||||||
|
IpAddr::V6(Ipv6Addr::from(input.arbitrary::<[u8; 16]>()?))
|
||||||
|
};
|
||||||
|
Ok(SocketAddr::new(address, port))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user