Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4104a1100a |
No files matched your search
@@ -42,7 +42,7 @@ jobs:
|
||||
# (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
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# 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
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -1,52 +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
|
||||
|
||||
env:
|
||||
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
|
||||
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
|
||||
|
||||
jobs:
|
||||
semver:
|
||||
name: cargo-semver-checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
# The CLI preserves the previous wrapper's `feature-group: all-features`
|
||||
# behavior without its GitHub API/cache dependency.
|
||||
- name: Install cargo-semver-checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-semver-checks
|
||||
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Check semver compatibility
|
||||
run: cargo semver-checks check-release --all-features
|
||||
@@ -1,109 +0,0 @@
|
||||
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,6 +16,34 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: actionlint handwritten workflows
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: >-
|
||||
.github/workflows/actions-security.yml,
|
||||
.github/workflows/ci.yml,
|
||||
.github/workflows/interop-netem.yml,
|
||||
.github/workflows/osv-scanner.yml,
|
||||
.github/workflows/rust-clippy.yml,
|
||||
.github/workflows/semver.yml,
|
||||
.github/workflows/supply-chain.yml
|
||||
|
||||
- name: actionlint generated release workflow
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
|
||||
with:
|
||||
files: .github/workflows/irtt-rs-release.yml
|
||||
shellcheck: false
|
||||
zizmor:
|
||||
name: zizmor
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,13 +60,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
|
||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||
# both workflow directories through its GitHub Actions rule set.
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
with:
|
||||
persona: regular
|
||||
inputs: |
|
||||
.github/workflows
|
||||
.gitea/workflows
|
||||
@@ -1,80 +0,0 @@
|
||||
name: Rust ARM CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: cargo ${{ matrix.task }} (${{ matrix.target }})
|
||||
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install musl toolchain
|
||||
if: matrix.musl
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
|
||||
- name: Run ${{ matrix.task }} without default features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -28,10 +29,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
components: rustfmt
|
||||
cache: false
|
||||
cache-key: fmt
|
||||
|
||||
- name: Check formatting
|
||||
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
||||
@@ -48,10 +49,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
components: clippy
|
||||
cache: false
|
||||
cache-key: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
@@ -78,22 +79,46 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
- task: check
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
@@ -109,10 +134,10 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache: false
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
@@ -147,9 +172,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache: false
|
||||
cache-key: docs
|
||||
|
||||
- name: Check docs
|
||||
run: |
|
||||
@@ -158,6 +183,3 @@ jobs:
|
||||
--all-features \
|
||||
--no-deps \
|
||||
--locked
|
||||
|
||||
- name: Run doctests
|
||||
run: cargo test --workspace --doc --all-features --locked
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Fuzz Smoke
|
||||
name: Proto Fuzz Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -16,9 +16,6 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
||||
CARGO_FUZZ_VERSION: 0.13.1
|
||||
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
|
||||
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
|
||||
FUZZ_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
fuzz-smoke:
|
||||
@@ -33,35 +30,24 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install stable Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache: false
|
||||
cache-key: fuzz
|
||||
|
||||
- name: Install nightly Rust toolchain
|
||||
# Installed alongside, but deliberately not made the directory
|
||||
# default: cargo-fuzz itself is built with the default stable
|
||||
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
||||
# the GNU-host nightly below. Building cargo-fuzz's own dependency
|
||||
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
|
||||
# `+nightly`. Building cargo-fuzz's own dependency tree under a
|
||||
# nightly override can trip build scripts (e.g. rustix) that gate
|
||||
# unstable internal attributes on detecting a nightly compiler.
|
||||
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
|
||||
run: rustup toolchain install nightly --profile minimal --no-self-update
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-fuzz
|
||||
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}"
|
||||
|
||||
- name: Build all fuzz targets
|
||||
# libFuzzer sanitizers require dynamically linked GNU binaries; the
|
||||
# runner's default Rust host can be static musl.
|
||||
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
|
||||
run: cargo +nightly fuzz build
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Run bounded fuzz smoke for every target
|
||||
@@ -71,9 +57,9 @@ jobs:
|
||||
# regression; this is not meant to be exhaustive fuzzing.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
|
||||
echo "::group::fuzz smoke: ${target}"
|
||||
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||
cargo +nightly fuzz run "${target}" -- \
|
||||
-max_total_time=10 \
|
||||
-timeout=5
|
||||
echo "::endgroup::"
|
||||
@@ -30,16 +30,15 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
cache: false
|
||||
cache-key: interop-netem
|
||||
|
||||
- name: Check host tools and network administration access
|
||||
- name: Check host tools
|
||||
run: |
|
||||
ip -V
|
||||
tc -V
|
||||
go version
|
||||
sudo -n true
|
||||
|
||||
- name: Install upstream irtt
|
||||
run: |
|
||||
@@ -63,14 +62,14 @@ jobs:
|
||||
run: |
|
||||
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
||||
if [ -n "$latest_summary" ]; then
|
||||
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
|
||||
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
|
||||
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload netem interop artifacts
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: interop-netem
|
||||
path: target/interop-netem
|
||||
@@ -31,7 +31,7 @@ permissions:
|
||||
jobs:
|
||||
scan-scheduled:
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
./
|
||||
scan-pr:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
components: clippy
|
||||
cache-key: clippy-sarif
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
@@ -0,0 +1,79 @@
|
||||
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,7 +173,6 @@ Use the narrowest useful checks first. For substantive workspace changes, final
|
||||
cargo fmt --all -- --check
|
||||
cargo check --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
|
||||
```
|
||||
|
||||
|
||||
@@ -28,18 +28,6 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-client
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
@@ -114,23 +102,6 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-rs
|
||||
|
||||
### 0.7.1
|
||||
|
||||
#### Fixed
|
||||
|
||||
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
|
||||
|
||||
### 0.7.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
|
||||
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
|
||||
|
||||
#### Changed
|
||||
|
||||
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
|
||||
|
||||
### 0.6.2
|
||||
|
||||
#### Added
|
||||
|
||||
Generated
+57
-57
@@ -213,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -223,9 +223,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -235,14 +235,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -385,9 +385,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
@@ -395,26 +395,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -496,9 +496,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -710,12 +710,12 @@ dependencies = [
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.5.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
@@ -749,7 +749,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
@@ -805,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -881,15 +881,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.4"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
@@ -943,9 +943,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1118,9 +1118,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.9.1"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
|
||||
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -1128,9 +1128,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.9.1"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
|
||||
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1138,9 +1138,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.9.1"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
|
||||
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1151,9 +1151,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.9.1"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
|
||||
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -1185,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.8",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1294,9 +1294,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
@@ -1569,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1628,9 +1628,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -1705,9 +1705,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1840,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1886,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1944,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1995,9 +1995,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2008,9 +2008,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2018,22 +2018,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||
irtt-client = { version = "0.5.0", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM rust:1.88-alpine AS builder
|
||||
|
||||
COPY --from=xx / /
|
||||
|
||||
|
||||
@@ -171,7 +171,6 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked
|
||||
cargo test --workspace --doc --all-features --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.6.2"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -47,7 +47,7 @@ server = ["dep:irtt-server", "dep:tokio"]
|
||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.29.0", optional = true }
|
||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
||||
match applet_basename(argv0) {
|
||||
"irtt-client" => Some(RequestedApplet::Client),
|
||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||
"irtt-server" => Some(RequestedApplet::Server),
|
||||
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
|
||||
"irtt-rs" => None,
|
||||
_ => None,
|
||||
}
|
||||
@@ -199,7 +199,6 @@ mod tests {
|
||||
detect_applet_from_argv0("irtt-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("custom-name"), None);
|
||||
}
|
||||
|
||||
@@ -11,22 +11,15 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||
pub struct ClientArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 0..,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||
long_help = "Server address/host, optionally prefixed with LABEL=. 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"
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
/// Read complete desired target sets from standard input in continuous mode.
|
||||
#[arg(
|
||||
long,
|
||||
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
|
||||
)]
|
||||
pub targets_stdin: bool,
|
||||
|
||||
/// Managed group pacing for multi-target mode.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -41,7 +34,7 @@ pub struct ClientArgs {
|
||||
default_value = "10s",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
@@ -93,16 +86,12 @@ impl ClientArgs {
|
||||
/// Targets are only required here. `--list-columns` returns before this is
|
||||
/// called, so listing columns still needs no target.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
}
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -170,14 +159,14 @@ mod tests {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.targets[0].label, None);
|
||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||
assert_eq!(args.interval, Duration::from_secs(1));
|
||||
assert_eq!(args.format, OutputFormat::Table);
|
||||
assert_eq!(args.columns, None);
|
||||
assert_eq!(args.header, HeaderMode::Auto);
|
||||
assert!(!args.targets_stdin);
|
||||
assert!(!args.is_continuous());
|
||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||
|
||||
@@ -265,8 +254,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
||||
assert!(parse(&["label="]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,21 +309,6 @@ mod tests {
|
||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert!(setup.targets.is_empty());
|
||||
assert!(setup.stdin_controlled);
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(
|
||||
config.completion,
|
||||
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||
);
|
||||
assert_eq!(config.max_live_target_generations, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_format_summary_policy_is_table_only() {
|
||||
assert!(OutputFormat::Table.prints_summary());
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
||||
io::{self, BufRead, Write},
|
||||
sync::{atomic::AtomicBool, Arc, Mutex},
|
||||
collections::HashSet,
|
||||
io::{self, Write},
|
||||
sync::atomic::AtomicBool,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_client::{
|
||||
managed::{
|
||||
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
|
||||
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
|
||||
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
||||
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
@@ -20,13 +20,13 @@ use super::{
|
||||
output::{EventRenderStats, OutputConfig},
|
||||
};
|
||||
|
||||
use crate::shared::client::expected_probe_count;
|
||||
use crate::shared::client::{
|
||||
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
|
||||
is_shutdown_requested,
|
||||
session::{
|
||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
||||
},
|
||||
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
@@ -41,7 +41,6 @@ const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
||||
|
||||
pub fn run_stream(
|
||||
args: ClientArgs,
|
||||
@@ -62,9 +61,6 @@ pub fn run_stream(
|
||||
args.verbose,
|
||||
)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
if setup.stdin_controlled {
|
||||
return run_stdin_stream(setup, output_config, shutdown_requested);
|
||||
}
|
||||
let continuous = args.is_continuous();
|
||||
let target_count = setup.target_count();
|
||||
|
||||
@@ -200,561 +196,6 @@ pub fn run_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StdinTargetSet {
|
||||
revision: u64,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StdinMailboxState {
|
||||
next_revision: u64,
|
||||
latest: Option<StdinTargetSet>,
|
||||
eof: bool,
|
||||
fatal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StdinMailbox {
|
||||
state: Mutex<StdinMailboxState>,
|
||||
}
|
||||
|
||||
impl StdinMailbox {
|
||||
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.next_revision = state.next_revision.saturating_add(1);
|
||||
state.latest = Some(StdinTargetSet {
|
||||
revision: state.next_revision,
|
||||
targets,
|
||||
});
|
||||
}
|
||||
|
||||
fn finish(&self) {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.eof = true;
|
||||
}
|
||||
|
||||
fn fail(&self, error: String) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.fatal = Some(error);
|
||||
state.latest = None;
|
||||
}
|
||||
|
||||
fn latest(&self) -> Option<StdinTargetSet> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.latest
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn clear_if_current(&self, revision: u64) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state
|
||||
.latest
|
||||
.as_ref()
|
||||
.is_some_and(|set| set.revision == revision)
|
||||
{
|
||||
state.latest = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_current(&self, revision: u64) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.latest
|
||||
.as_ref()
|
||||
.is_some_and(|set| set.revision == revision)
|
||||
}
|
||||
|
||||
fn fatal(&self) -> Option<String> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.fatal
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.eof
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin_record<R: BufRead>(
|
||||
reader: &mut R,
|
||||
buffer: &mut Vec<u8>,
|
||||
) -> io::Result<Option<String>> {
|
||||
buffer.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
if buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
|
||||
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
|
||||
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
|
||||
reader.consume(1);
|
||||
buffer.pop();
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
|
||||
let before_newline = &available[..newline];
|
||||
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
buffer.extend_from_slice(before_newline);
|
||||
reader.consume(newline + 1);
|
||||
if buffer.last() == Some(&b'\r') {
|
||||
buffer.pop();
|
||||
}
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
|
||||
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
|
||||
let take = available.len().min(remaining);
|
||||
buffer.extend_from_slice(&available[..take]);
|
||||
reader.consume(take);
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
|
||||
let mut line = 0_u64;
|
||||
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
loop {
|
||||
match read_stdin_record(reader, &mut record) {
|
||||
Ok(Some(record)) => {
|
||||
line = line.saturating_add(1);
|
||||
if record.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||
Ok(targets) => {
|
||||
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
|
||||
}
|
||||
Err(error) => {
|
||||
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
mailbox.finish();
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
|
||||
thread::Builder::new()
|
||||
.name("irtt-targets-stdin".to_owned())
|
||||
.spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
read_stdin_target_sets(&mut stdin.lock(), &mailbox);
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RetryAfterStatus {
|
||||
revision: u64,
|
||||
status: Arc<ManagedStatus>,
|
||||
}
|
||||
|
||||
fn retry_waits_for_status_change(
|
||||
retry_after: &mut Option<RetryAfterStatus>,
|
||||
target_set: &StdinTargetSet,
|
||||
status: &Arc<ManagedStatus>,
|
||||
) -> bool {
|
||||
let Some(retry) = retry_after else {
|
||||
return false;
|
||||
};
|
||||
if retry.revision != target_set.revision {
|
||||
*retry_after = None;
|
||||
return false;
|
||||
}
|
||||
Arc::ptr_eq(&retry.status, status)
|
||||
}
|
||||
|
||||
fn retry_after_capacity_rejection(
|
||||
mailbox: &StdinMailbox,
|
||||
target_set: &StdinTargetSet,
|
||||
status: Arc<ManagedStatus>,
|
||||
) -> Option<RetryAfterStatus> {
|
||||
mailbox
|
||||
.is_current(target_set.revision)
|
||||
.then_some(RetryAfterStatus {
|
||||
revision: target_set.revision,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_latest_stdin_target_set(
|
||||
handle: &ManagedClientHandle,
|
||||
mailbox: &StdinMailbox,
|
||||
retry_after: &mut Option<RetryAfterStatus>,
|
||||
) -> Result<(), String> {
|
||||
let Some(target_set) = mailbox.latest() else {
|
||||
*retry_after = None;
|
||||
return Ok(());
|
||||
};
|
||||
let status = handle.status();
|
||||
if retry_waits_for_status_change(retry_after, &target_set, &status) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let receipt = handle
|
||||
.update_targets(target_set.targets.clone())
|
||||
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
|
||||
match receipt.blocking_wait() {
|
||||
Ok(_) => {
|
||||
mailbox.clear_if_current(target_set.revision);
|
||||
*retry_after = None;
|
||||
Ok(())
|
||||
}
|
||||
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundedTargetSet {
|
||||
limit: usize,
|
||||
order: VecDeque<TargetInstance>,
|
||||
members: HashSet<TargetInstance>,
|
||||
}
|
||||
|
||||
impl BoundedTargetSet {
|
||||
fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
limit,
|
||||
order: VecDeque::with_capacity(limit),
|
||||
members: HashSet::with_capacity(limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, target: TargetInstance) -> bool {
|
||||
if !self.members.insert(target.clone()) {
|
||||
return false;
|
||||
}
|
||||
if self.order.len() == self.limit {
|
||||
let evicted = self
|
||||
.order
|
||||
.pop_front()
|
||||
.expect("bounded target order is nonempty");
|
||||
self.members.remove(&evicted);
|
||||
}
|
||||
self.order.push_back(target);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn process_stdin_event<W: Write>(
|
||||
event: ManagedEvent,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
terminal_targets: &mut BoundedTargetSet,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::TargetStateChanged { target, .. } => {
|
||||
stats
|
||||
.entry(target)
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
}
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
print_events_with_stats(
|
||||
stream_output,
|
||||
std::slice::from_ref(&event),
|
||||
Some(target.id.as_str()),
|
||||
collector,
|
||||
)?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome }
|
||||
if terminal_targets.insert(outcome.target.clone()) =>
|
||||
{
|
||||
report_target_failure(&outcome);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_stdin_events<W: Write>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
terminal_targets: &mut BoundedTargetSet,
|
||||
dropped_events: &mut u64,
|
||||
) -> io::Result<ManagedDrainState> {
|
||||
drain_managed_events(events, dropped_events, |event| {
|
||||
process_stdin_event(event, stream_output, stats, terminal_targets)
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_final_stdin_events<W: Write>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
terminal_targets: &mut BoundedTargetSet,
|
||||
dropped_events: &mut u64,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
match events.try_recv() {
|
||||
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
|
||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||
*dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_stdin_stats(
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
status: &ManagedStatus,
|
||||
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
|
||||
) {
|
||||
let live = status
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.target.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
stats.retain(|target, _| {
|
||||
live.contains(target)
|
||||
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
|
||||
});
|
||||
}
|
||||
|
||||
fn snapshot_stdin_summary_targets(
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
status: &ManagedStatus,
|
||||
) -> BTreeSet<TargetInstance> {
|
||||
let targets = status
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.target.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for target in &targets {
|
||||
stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StdinStop {
|
||||
Interrupted,
|
||||
Eof,
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
fn stdin_stop_request(
|
||||
shutdown_requested: &AtomicBool,
|
||||
mailbox: &StdinMailbox,
|
||||
) -> Option<StdinStop> {
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
Some(StdinStop::Interrupted)
|
||||
} else if let Some(error) = mailbox.fatal() {
|
||||
Some(StdinStop::Fatal(error))
|
||||
} else if mailbox.is_finished() {
|
||||
Some(StdinStop::Eof)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn run_stdin_stream(
|
||||
setup: ManagedRunSetup,
|
||||
output_config: OutputConfig,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
||||
setup.managed_config(),
|
||||
setup.managed_targets(),
|
||||
)?;
|
||||
let handle = owner.handle();
|
||||
let mailbox = Arc::new(StdinMailbox::default());
|
||||
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
|
||||
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
show_running_only_summary_note: false,
|
||||
out: &mut stdout,
|
||||
};
|
||||
let mut stats = BTreeMap::new();
|
||||
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||
let mut retry_after = None;
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut stop_requested = false;
|
||||
let mut stop = None;
|
||||
let mut summary_targets = None;
|
||||
let mut subscription_closed = false;
|
||||
|
||||
loop {
|
||||
let status = handle.status();
|
||||
if stop.is_none() {
|
||||
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
||||
stop = Some(requested_stop);
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
} else if let Err(error) =
|
||||
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
|
||||
{
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
||||
stop = Some(StdinStop::Fatal(error));
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let drain_state = drain_stdin_events(
|
||||
&mut events,
|
||||
&mut stream_output,
|
||||
&mut stats,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
)?;
|
||||
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
|
||||
match drain_state {
|
||||
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
||||
ManagedDrainState::Closed => subscription_closed = true,
|
||||
ManagedDrainState::BudgetExhausted => {}
|
||||
}
|
||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = owner.join()?;
|
||||
drain_final_stdin_events(
|
||||
&mut events,
|
||||
&mut stream_output,
|
||||
&mut stats,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
)?;
|
||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
report_target_failure(target);
|
||||
}
|
||||
}
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
eprintln!(
|
||||
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
);
|
||||
}
|
||||
|
||||
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
|
||||
stream_output.print_final_summary = print_summary;
|
||||
stream_output.show_running_only_summary_note = print_summary;
|
||||
if let Some(summary_targets) = summary_targets {
|
||||
let multi_target = summary_targets.len() > 1;
|
||||
for target in summary_targets {
|
||||
let stats = stats
|
||||
.get(&target)
|
||||
.expect("summary snapshot retains every selected target collector");
|
||||
if multi_target && stream_output.config.prints_summary() {
|
||||
writeln!(stream_output.out)?;
|
||||
writeln!(
|
||||
stream_output.out,
|
||||
"target: {} (generation {})",
|
||||
target.id, target.generation
|
||||
)?;
|
||||
}
|
||||
stream_output.print_summary(stats)?;
|
||||
}
|
||||
}
|
||||
stream_output.out.flush()?;
|
||||
|
||||
match stop {
|
||||
Some(StdinStop::Fatal(error)) => Err(error.into()),
|
||||
_ => match outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Err(format!("managed driver failed: {failure}").into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn process_event<W: Write>(
|
||||
event: ManagedEvent,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
@@ -943,218 +384,9 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::managed::{
|
||||
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
|
||||
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
|
||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
|
||||
};
|
||||
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)));
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
fn long_finite_args() -> ClientArgs {
|
||||
use clap::Parser;
|
||||
|
||||
@@ -106,22 +106,10 @@ pub struct ServerArgs {
|
||||
pub timestamp_allowance: Option<TimestampAllowanceArg>,
|
||||
|
||||
/// 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(
|
||||
long,
|
||||
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."
|
||||
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
|
||||
)]
|
||||
pub no_dscp: bool,
|
||||
}
|
||||
@@ -560,43 +548,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `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")], || {
|
||||
|
||||
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||
pub struct TuiArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 1..,
|
||||
required = true,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
@@ -58,7 +58,6 @@ impl TuiArgs {
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -108,7 +107,8 @@ mod tests {
|
||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.targets[0].label, None);
|
||||
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
@@ -181,8 +181,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["=127.0.0.1:2112"]).is_err());
|
||||
assert!(parse(&["label="]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,12 +9,9 @@ pub use args::{
|
||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||
};
|
||||
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
|
||||
pub use prepare::{
|
||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
|
||||
pub use session::is_shutdown_requested;
|
||||
pub use targets::{
|
||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||
PreparedTarget, TargetArg, TargetSpec,
|
||||
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||
TargetSpec,
|
||||
};
|
||||
@@ -22,9 +22,6 @@ use super::{
|
||||
|
||||
/// Capacity of the lossy managed presentation event channel.
|
||||
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
|
||||
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
|
||||
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||
|
||||
/// Target and pacing arguments common to the client applets.
|
||||
///
|
||||
@@ -34,7 +31,6 @@ pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||
pub struct TargetSelection<'a> {
|
||||
pub targets: &'a [TargetArg],
|
||||
pub pacing: GroupPacingArg,
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
/// Everything a managed run needs, prepared and validated together.
|
||||
@@ -50,8 +46,6 @@ pub struct ManagedRunSetup {
|
||||
pub client: ClientConfig,
|
||||
/// Send coordination across active targets.
|
||||
pub pacing: ManagedPacing,
|
||||
/// Whether this stream may replace its desired set from stdin.
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
impl ManagedRunSetup {
|
||||
@@ -76,7 +70,7 @@ impl ManagedRunSetup {
|
||||
/// Managed driver configuration for this run.
|
||||
pub fn managed_config(&self) -> ManagedClientConfig {
|
||||
let target_count = self.target_count();
|
||||
let mut config = ManagedClientConfig {
|
||||
ManagedClientConfig {
|
||||
client: self.client.clone(),
|
||||
pacing: self.pacing,
|
||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||
@@ -84,13 +78,7 @@ impl ManagedRunSetup {
|
||||
outcome_history_limit: target_count,
|
||||
max_live_target_generations: target_count,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
if self.stdin_controlled {
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
|
||||
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,22 +91,12 @@ pub fn prepare_managed_run(
|
||||
duration: Duration,
|
||||
selection: TargetSelection<'_>,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
let specs = if selection.stdin_controlled {
|
||||
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
|
||||
return Err(format!(
|
||||
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
|
||||
));
|
||||
}
|
||||
super::targets::target_specs_with_empty(selection.targets, true)?
|
||||
} else {
|
||||
target_specs(selection.targets)?
|
||||
};
|
||||
let specs = target_specs(selection.targets)?;
|
||||
let targets = prepare_managed_targets(specs)?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,11 +119,17 @@ mod tests {
|
||||
}
|
||||
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg::new(addr)
|
||||
TargetArg {
|
||||
label: None,
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg::new(format!("{label}={addr}"))
|
||||
TargetArg {
|
||||
label: Some(label.to_owned()),
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
@@ -153,15 +137,7 @@ mod tests {
|
||||
pacing: GroupPacingArg,
|
||||
duration: Duration,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&common(&[]),
|
||||
duration,
|
||||
TargetSelection {
|
||||
targets,
|
||||
pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -236,7 +212,6 @@ mod tests {
|
||||
TargetSelection {
|
||||
targets: &targets,
|
||||
pacing: GroupPacingArg::Staggered,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1,99 +1,26 @@
|
||||
use std::{collections::HashSet, fmt};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
|
||||
ClientAuthConfig,
|
||||
};
|
||||
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
|
||||
|
||||
/// 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)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetArg {
|
||||
input: String,
|
||||
pub label: Option<String>,
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
impl TargetArg {
|
||||
pub fn new(input: impl Into<String>) -> Self {
|
||||
Self {
|
||||
input: input.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetArg {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("TargetArg(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture one positional target without exposing it through Clap diagnostics.
|
||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
Ok(TargetArg::new(input))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum TargetAuth {
|
||||
Inherit,
|
||||
Override(Vec<u8>),
|
||||
Disable,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Inherit => f.write_str("Inherit"),
|
||||
Self::Override(_) => f.write_str("Override(<redacted>)"),
|
||||
Self::Disable => f.write_str("Disable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
pub addr: String,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TargetSpec")
|
||||
.field("label", &self.label)
|
||||
.field("addr", &self.addr)
|
||||
.field("auth", &self.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedTarget {
|
||||
pub label: String,
|
||||
pub managed: ManagedTargetConfig,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field(
|
||||
"auth",
|
||||
&match &self.managed.auth {
|
||||
None => TargetAuth::Inherit,
|
||||
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
|
||||
TargetAuth::Override(Vec::new())
|
||||
}
|
||||
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum GroupPacingArg {
|
||||
Staggered,
|
||||
@@ -109,383 +36,191 @@ impl From<GroupPacingArg> for ManagedPacing {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TargetParseError {
|
||||
EmptyLabel,
|
||||
EmptyAddress,
|
||||
InvalidEscape,
|
||||
EmptyTarget,
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let message = match self {
|
||||
Self::EmptyLabel => "target label must not be empty",
|
||||
Self::EmptyAddress => "target address must not be empty",
|
||||
Self::InvalidEscape => "invalid target escape sequence",
|
||||
Self::EmptyTarget => "target must not be empty",
|
||||
};
|
||||
f.write_str(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
parts.push(&input[start..index]);
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
parts.push(&input[start..]);
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn unescape(input: &str) -> Result<String, TargetParseError> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut escaped = false;
|
||||
for ch in input.chars() {
|
||||
if escaped {
|
||||
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
|
||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
match input.split_once('=') {
|
||||
None => Ok(TargetArg {
|
||||
label: None,
|
||||
addr: input.to_owned(),
|
||||
}),
|
||||
Some((label, addr)) => {
|
||||
if label.is_empty() {
|
||||
return Err("target label must not be empty".to_owned());
|
||||
}
|
||||
output.push(ch);
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else {
|
||||
output.push(ch);
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
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> {
|
||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
let mut specs = Vec::with_capacity(targets.len());
|
||||
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
|
||||
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
|
||||
let label = match explicit_label {
|
||||
Some(label) => label,
|
||||
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
|
||||
for target in targets {
|
||||
let label = match &target.label {
|
||||
Some(label) => label.clone(),
|
||||
None => {
|
||||
let count = unlabeled_counts.entry(addr.clone()).or_default();
|
||||
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
|
||||
*count += 1;
|
||||
if *count == 1 {
|
||||
addr.clone()
|
||||
target.addr.clone()
|
||||
} else {
|
||||
format!("{}#{}", addr, *count)
|
||||
format!("{}#{}", target.addr, *count)
|
||||
}
|
||||
}
|
||||
};
|
||||
specs.push(TargetSpec { label, addr, auth });
|
||||
specs.push(TargetSpec {
|
||||
label,
|
||||
addr: target.addr.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if specs.is_empty() && !allow_empty {
|
||||
if specs.is_empty() {
|
||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||
}
|
||||
|
||||
let mut labels = HashSet::new();
|
||||
for spec in &specs {
|
||||
if !labels.insert(spec.label.clone()) {
|
||||
return Err("duplicate target label".to_owned());
|
||||
return Err(format!("duplicate target label {:?}", spec.label));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs_with_empty(targets, false)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = match spec.auth {
|
||||
TargetAuth::Inherit => None,
|
||||
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
|
||||
hmac_key: Some(hmac_key),
|
||||
}),
|
||||
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
|
||||
};
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
label: spec.label.clone(),
|
||||
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
|
||||
});
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Parse one complete stdin target set after its line terminator was removed.
|
||||
///
|
||||
/// Commas frame stdin elements only; positional target arguments do not use
|
||||
/// this framing and may therefore contain raw commas.
|
||||
pub fn parse_stdin_target_set(
|
||||
record: &str,
|
||||
maximum_targets: usize,
|
||||
) -> Result<Vec<PreparedTarget>, String> {
|
||||
if record == "[]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
|
||||
if elements.len() > maximum_targets {
|
||||
return Err(format!(
|
||||
"target set exceeds the {maximum_targets}-target limit"
|
||||
));
|
||||
}
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: None,
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg {
|
||||
label: Some(label.to_owned()),
|
||||
addr: addr.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
fn parse_target_without_equals_is_unlabeled() {
|
||||
let target = parse_target("host.example").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "host.example");
|
||||
|
||||
let target = parse_target("host.example:2112").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "host.example:2112");
|
||||
|
||||
let target = parse_target("[::1]:2112").unwrap();
|
||||
assert_eq!(target.label, None);
|
||||
assert_eq!(target.addr, "[::1]:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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");
|
||||
fn parse_target_with_equals_splits_on_first() {
|
||||
let target = parse_target("eu=host.example").unwrap();
|
||||
assert_eq!(target.label, Some("eu".to_owned()));
|
||||
assert_eq!(target.addr, "host.example");
|
||||
|
||||
let target = parse_target("eu=host.example:2112").unwrap();
|
||||
assert_eq!(target.label, Some("eu".to_owned()));
|
||||
assert_eq!(target.addr, "host.example:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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());
|
||||
fn parse_target_rejects_empty_label_or_address() {
|
||||
assert!(parse_target("=host.example").is_err());
|
||||
assert!(parse_target("eu=").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
|
||||
let targets = vec![
|
||||
unlabeled("host-a:2112"),
|
||||
labeled("host-a:2112", "host-b:2112"),
|
||||
];
|
||||
let err = target_specs(&targets).unwrap_err();
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_with_mixed_labels() {
|
||||
let targets = vec![
|
||||
unlabeled("local"),
|
||||
labeled("eu", "eu.example"),
|
||||
unlabeled("backup"),
|
||||
labeled("us", "us.example"),
|
||||
];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "backup", "us"]);
|
||||
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
|
||||
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
|
||||
let specs = target_specs(&targets).unwrap();
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
|
||||
assert_eq!(labels, ["foo", "eu", "foo#2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
let targets = vec![
|
||||
unlabeled("host.example"),
|
||||
unlabeled("host.example"),
|
||||
labeled("host.example#2", "other.example"),
|
||||
];
|
||||
let err = target_specs(&targets).unwrap_err();
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
|
||||
let specs = vec![
|
||||
TargetSpec {
|
||||
label: "a".to_owned(),
|
||||
addr: "example.test".to_owned(),
|
||||
},
|
||||
TargetSpec {
|
||||
label: "b".to_owned(),
|
||||
addr: "example.test".to_owned(),
|
||||
},
|
||||
];
|
||||
let targets = prepare_managed_targets(specs).unwrap();
|
||||
assert_eq!(targets[0].managed.server_addr, "example.test");
|
||||
assert_eq!(targets[1].managed.server_addr, "example.test");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
failure is never a public error and is never logged from the library.
|
||||
|
||||
### Best-effort kernel-ID correlation
|
||||
### Correlation invariant: kernel ID == wire sequence
|
||||
|
||||
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
|
||||
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.
|
||||
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
|
||||
sequence number — no separate correlation map. This is safe because:
|
||||
|
||||
The effect is confined to the optional timing enhancement. A kernel timestamp
|
||||
whose ID cannot be matched to a pending or retained timed-out probe is
|
||||
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
|
||||
userspace fallback. **After every failed probe submission, disable kernel-ID
|
||||
correlation for the rest of that session.** A gap means an otherwise plausible
|
||||
later ID can name the wrong probe, so subsequent error-queue timestamps must
|
||||
be discarded rather than attached by ID. This is safe because:
|
||||
|
||||
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
|
||||
successful Open (`SessionMachine::commit_open` builds a fresh
|
||||
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
|
||||
`commit_probe_sent`, after a confirmed successful socket send. TX
|
||||
timestamping is enabled after that same successful Open and before any probe
|
||||
is sent, so the normal zero points line up.
|
||||
- **A failed/nonblocking send never consumes an ID.** Linux increments the
|
||||
per-socket ID counter (`sk->sk_tskey`) speculatively while building a
|
||||
datagram and rolls the increment back (`atomic_dec`) on every error path
|
||||
out of `__ip_append_data`/`__ip6_append_data`
|
||||
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
|
||||
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
|
||||
gap in, or otherwise advance, the ID the kernel will assign to the next
|
||||
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
|
||||
performs the same increment for the non-corked path.) Client-side, the
|
||||
existing send transaction only calls `commit_probe_sent` — which is the
|
||||
only thing that advances `next_wire_seq` — after a successful socket send;
|
||||
a `WouldBlock` result never reaches it.
|
||||
- **The two counters start together and advance together.** `wire_seq`
|
||||
starts at 0 on every successful Open (`SessionMachine::commit_open` builds
|
||||
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
|
||||
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
|
||||
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.5.2"
|
||||
version = "0.5.0"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -44,8 +44,6 @@ required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[dev-dependencies]
|
||||
# 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" }
|
||||
irtt-server.workspace = true
|
||||
proptest = "1.11.0"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
@@ -242,31 +242,6 @@ impl AsyncClientTestHooks {
|
||||
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
||||
/// methods are polled by the caller and require a current Tokio runtime with I/O
|
||||
/// and time enabled.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Use this low-level adapter when the application already owns Tokio and wants
|
||||
/// to drive the session lifecycle itself. This example type-checks without
|
||||
/// contacting a server.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{AsyncClient, ClientConfig};
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
|
||||
/// let outcome = client.open().await?;
|
||||
/// println!("opened: {outcome:?}");
|
||||
/// let sent = client.send_probe().await?;
|
||||
/// println!("sent: {sent:?}");
|
||||
/// let received = client.recv().await?;
|
||||
/// println!("received: {received:?}");
|
||||
/// let timed_out = client.poll_timeouts()?;
|
||||
/// println!("timeouts: {timed_out:?}");
|
||||
/// let closed = client.close().await?;
|
||||
/// println!("closed: {closed:?}");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct AsyncClient {
|
||||
socket: tokio::net::UdpSocket,
|
||||
@@ -633,14 +608,8 @@ impl AsyncClient {
|
||||
// No sent_at measurement, no machine commit, no schedule
|
||||
// commit: the transaction is fully discarded and retried
|
||||
// with a fresh send_anchor on the next loop iteration.
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
|
||||
@@ -103,31 +103,6 @@ use crate::{
|
||||
/// session, send probes, receive replies, poll timeouts, and close. Callers
|
||||
/// that do not need to own this loop can use the unified managed API in
|
||||
/// [`crate::managed`] when the `tokio` feature is enabled.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
|
||||
/// polling between sends; see the standalone example for a complete loop.
|
||||
/// This example type-checks without contacting a server:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{Client, ClientConfig};
|
||||
///
|
||||
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut client = Client::connect(ClientConfig::default())?;
|
||||
/// let outcome = client.open()?;
|
||||
/// println!("opened: {outcome:?}");
|
||||
/// let sent = client.send_probe()?;
|
||||
/// println!("sent: {sent:?}");
|
||||
/// let received = client.recv_once()?;
|
||||
/// println!("received: {received:?}");
|
||||
/// let timed_out = client.poll_timeouts()?;
|
||||
/// println!("timeouts: {timed_out:?}");
|
||||
/// let closed = client.close()?;
|
||||
/// println!("closed: {closed:?}");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
runtime: SessionMachine,
|
||||
@@ -188,13 +163,12 @@ impl Client {
|
||||
///
|
||||
/// On success, returns the negotiated open outcome and transitions the
|
||||
/// client into either an open probe session or completed no-test state.
|
||||
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
|
||||
/// unrelated datagrams are ignored until the current attempt's absolute
|
||||
/// deadline, so one attempt may consume several datagrams without
|
||||
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated,
|
||||
/// or unauthenticated datagrams are ignored until the current attempt's
|
||||
/// absolute deadline, so one attempt may consume several datagrams without
|
||||
/// retransmitting. Silence or ignored traffic eventually produces
|
||||
/// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
|
||||
/// incompatible Open reply from the connected peer is terminal; when HMAC
|
||||
/// is configured, that recognition additionally requires authentication.
|
||||
/// [`ClientError::OpenTimeout`], while authenticated incompatibility remains
|
||||
/// terminal.
|
||||
///
|
||||
/// When a trusted reply allocates a token but later negotiation or socket
|
||||
/// preparation fails, the client sends a best-effort cleanup close and
|
||||
@@ -549,18 +523,11 @@ impl Client {
|
||||
.unwrap_or_else(Instant::now);
|
||||
#[cfg(test)]
|
||||
if fail_send {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(io::Error::other(
|
||||
"injected probe send failure",
|
||||
)));
|
||||
}
|
||||
let bytes = match socket.send(&prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(error));
|
||||
}
|
||||
};
|
||||
let bytes = socket.send(&prepared.bytes)?;
|
||||
#[cfg(not(test))]
|
||||
let send_finished_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,31 +46,6 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
||||
/// request and the local client behavior used to drive the UDP socket. Values
|
||||
/// that are negotiated by the server are available after opening the session
|
||||
/// through [`NegotiatedParams`](crate::NegotiatedParams).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Configure a finite run with larger probes and accept documented server
|
||||
/// restrictions. Use `duration: None` for continuous mode, or
|
||||
/// [`RunMode::NoTest`] for negotiation without probes.
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use irtt_client::{ClientConfig, NegotiationPolicy};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// server_addr: "example.net:2112".into(),
|
||||
/// duration: Some(Duration::from_secs(10)),
|
||||
/// interval: Duration::from_millis(250),
|
||||
/// length: 1200,
|
||||
/// negotiation_policy: NegotiationPolicy::Loose,
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.duration, Some(Duration::from_secs(10)));
|
||||
/// assert_eq!(config.interval, Duration::from_millis(250));
|
||||
/// assert_eq!(config.length, 1200);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
/// Remote server name or address.
|
||||
@@ -201,29 +176,6 @@ impl Default for ClientConfig {
|
||||
/// These settings affect how the client binds, resolves, and receives from the
|
||||
/// socket. They do not change the IRTT protocol parameters negotiated with the
|
||||
/// server.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
|
||||
///
|
||||
/// ```
|
||||
/// use std::net::{Ipv4Addr, SocketAddr};
|
||||
///
|
||||
/// use irtt_client::{ClientConfig, SocketConfig};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// socket_config: SocketConfig {
|
||||
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
|
||||
/// ttl: Some(32),
|
||||
/// ipv4_only: true,
|
||||
/// ..SocketConfig::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.socket_config.ttl, Some(32));
|
||||
/// assert!(config.socket_config.ipv4_only);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SocketConfig {
|
||||
/// Local address to bind before connecting the UDP socket.
|
||||
|
||||
@@ -260,19 +260,6 @@ pub struct RttSample {
|
||||
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||
/// which is non-negative and represented by [`Duration`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Signed timing values retain a negative result instead of clamping clock
|
||||
/// skew or server processing adjustments to zero:
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_client::SignedDuration;
|
||||
///
|
||||
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
|
||||
/// assert!(adjusted_rtt.is_negative());
|
||||
/// assert_eq!(adjusted_rtt.as_micros(), -250);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SignedDuration {
|
||||
ns: i128,
|
||||
|
||||
@@ -250,11 +250,15 @@ fn dynamic_update_reaches_background_worker() {
|
||||
.unwrap();
|
||||
let handle = owner.handle();
|
||||
|
||||
let acknowledgement = handle
|
||||
.update_targets(vec![target(&server)])
|
||||
let acknowledgement = test_runtime().block_on(async {
|
||||
timeout(
|
||||
Duration::from_secs(2),
|
||||
handle.update_targets(vec![target(&server)]).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.blocking_wait()
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(acknowledgement.sequence, 1);
|
||||
server.wait_probe();
|
||||
test_runtime().block_on(async {
|
||||
|
||||
@@ -44,42 +44,6 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
||||
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
|
||||
|
||||
/// Entry point for constructing a unified Tokio managed task.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Construct the task and its separate control handle, subscribe before
|
||||
/// starting the task, then run it under the caller's Tokio runtime. A reachable
|
||||
/// server is required at runtime, so this example only type-checks.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{ClientConfig, ClientEvent};
|
||||
/// use irtt_client::managed::{
|
||||
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
|
||||
/// };
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ManagedClientConfig {
|
||||
/// client: ClientConfig {
|
||||
/// duration: Some(std::time::Duration::from_secs(10)),
|
||||
/// ..ClientConfig::default()
|
||||
/// },
|
||||
/// ..ManagedClientConfig::default()
|
||||
/// };
|
||||
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
|
||||
/// let (task, handle) = ManagedClient::task(config, targets)?;
|
||||
/// let mut events = handle.subscribe()?;
|
||||
/// let driver = tokio::spawn(task);
|
||||
///
|
||||
/// while let Ok(event) = events.recv().await {
|
||||
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
|
||||
/// println!("received a probe reply");
|
||||
/// }
|
||||
/// }
|
||||
/// let outcome = driver.await?;
|
||||
/// println!("run ended: {:?}", outcome.end_reason);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManagedClient;
|
||||
|
||||
@@ -337,19 +301,6 @@ pub struct ManagedCommandReceipt {
|
||||
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
}
|
||||
|
||||
impl ManagedCommandReceipt {
|
||||
/// Block until the target-set transaction is applied or rejected.
|
||||
///
|
||||
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
|
||||
/// must not be called from an asynchronous runtime; use the [`Future`]
|
||||
/// implementation there instead.
|
||||
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
self.receiver
|
||||
.blocking_recv()
|
||||
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedCommandReceipt {
|
||||
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
@@ -1649,30 +1600,6 @@ impl ManagedClientTask {
|
||||
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
||||
}
|
||||
|
||||
/// The next stagger gate after a send was accepted at `accepted_at`.
|
||||
///
|
||||
/// The gate advances on its **own** cadence rather than being re-anchored
|
||||
/// on every observed accept time. Anchoring on `accepted_at` folds each
|
||||
/// send's wakeup latency into the next slot and then into every slot after
|
||||
/// it, so the gate — and with it the whole group's send cadence — drifts
|
||||
/// later without bound while the per-target schedules stay on their
|
||||
/// absolute grid. The drift is not hypothetical: it shows up as a
|
||||
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
|
||||
/// interval, as probe slots the schedule then skips.
|
||||
///
|
||||
/// `accepted_at` is still the anchor in the two cases where continuing the
|
||||
/// old cadence would be wrong: there is no previous gate, or the pacer has
|
||||
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
|
||||
/// stranded in the past from releasing a burst of catch-up sends once the
|
||||
/// group starts moving again.
|
||||
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
|
||||
let base = match self.send_gate {
|
||||
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
|
||||
_ => accepted_at,
|
||||
};
|
||||
base.checked_add(spacing)
|
||||
}
|
||||
|
||||
fn record_stagger_acceptance(
|
||||
&mut self,
|
||||
result: SendResult,
|
||||
@@ -1686,7 +1613,7 @@ impl ManagedClientTask {
|
||||
return;
|
||||
};
|
||||
self.last_stagger_send = Some(accepted_at);
|
||||
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||
self.send_gate = accepted_at.checked_add(spacing);
|
||||
#[cfg(test)]
|
||||
if let Some(observations) = &self.stagger_observations {
|
||||
observations.lock().unwrap().push((_active, spacing));
|
||||
|
||||
@@ -1576,37 +1576,6 @@ fn authenticated_peer_close_outcome() {
|
||||
assert!(!has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_leaves_other_explicitly_managed_targets_running() {
|
||||
let closing = start_server(ServerBehavior::PeerClose, None);
|
||||
let continuing = start_server(ServerBehavior::Echo, None);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.client.duration = None;
|
||||
let (task, handle) = ManagedClient::task(
|
||||
managed,
|
||||
vec![
|
||||
target("closing", closing.addr),
|
||||
target("continuing", continuing.addr),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = runtime();
|
||||
let mut task = Box::pin(task);
|
||||
|
||||
drive_task_until(&runtime, &mut task, |task| {
|
||||
handle.status().peer_closed_target_outcomes == 1
|
||||
&& task.targets[1].counters.packets_sent != 0
|
||||
});
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||
|
||||
let stop = handle.stop();
|
||||
runtime.block_on(task);
|
||||
runtime.block_on(stop);
|
||||
closing.finish();
|
||||
assert!(!probes(&continuing.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_during_stop_drain_remains_authoritative() {
|
||||
let key = b"managed-drain-peer-close".to_vec();
|
||||
@@ -1895,78 +1864,6 @@ fn stagger_gate_tracks_active_membership() {
|
||||
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]
|
||||
fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
|
||||
let target_count = TIMEOUT_WORK_BUDGET + 1;
|
||||
|
||||
@@ -39,8 +39,7 @@ pub(crate) struct PendingProbe {
|
||||
/// eligible only for upstream one-way delay after local plausibility
|
||||
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
|
||||
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
|
||||
/// upstream one-way delay when this is absent, implausible, or kernel-ID
|
||||
/// correlation was invalidated by a failed probe submission.
|
||||
/// upstream one-way delay when this is absent or implausible.
|
||||
pub kernel_tx_timestamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,14 @@
|
||||
//! After a successful Open, the client adapter best-effort upgrades the
|
||||
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
|
||||
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
|
||||
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
|
||||
//! after successful sends, so it is used as a best-effort correlation ID.
|
||||
//! A kernel ID can theoretically be consumed by a send that later fails,
|
||||
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards
|
||||
//! unmatched or implausible records; the userspace `sent_at` timestamp remains
|
||||
//! the fallback. After a probe send failure, correlation is disabled for that
|
||||
//! session so later IDs cannot be misattributed. It performs a small bounded,
|
||||
//! 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.
|
||||
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
|
||||
//! submitted datagram is used directly as the probe's wire sequence number
|
||||
//! (see the client crate's `AGENTS.md` for the correlation invariant this
|
||||
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
|
||||
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
|
||||
//! 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::{
|
||||
io::{self, IoSliceMut},
|
||||
|
||||
@@ -624,14 +624,22 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
|
||||
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
|
||||
// on a connected loopback UDP socket (for example through socket-buffer
|
||||
// saturation) has no deterministic OS-level mechanism in this environment,
|
||||
// and the repository's testing policy forbids sleep/saturation-based
|
||||
// flakiness. The correlation is intentionally best effort: a kernel ID can
|
||||
// theoretically be consumed by a send that later fails. The deterministic
|
||||
// adapter test covers only the local half: an injected `WouldBlock` does
|
||||
// not advance the local wire-sequence counter. Any later unmatched kernel
|
||||
// timestamp is discarded and the userspace `sent_at` remains the fallback.
|
||||
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not
|
||||
// consume an automatic OPT_ID is not included here. Forcing a reliable,
|
||||
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
|
||||
// socket-buffer saturation) has no deterministic OS-level mechanism
|
||||
// available in this environment, and the repository's testing policy
|
||||
// explicitly forbids faking coverage with sleep/saturation-based
|
||||
// flakiness. That safety is instead established from Linux kernel
|
||||
// source: `_sock_tx_timestamp` (include/net/sock.h) and
|
||||
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
|
||||
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before
|
||||
// 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,7 +60,6 @@ struct ActiveSession {
|
||||
pending: PendingMap,
|
||||
timed_out: TimedOutMap,
|
||||
completed: CompletedSet,
|
||||
kernel_tx_correlation_valid: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -424,27 +423,15 @@ 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
|
||||
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
|
||||
/// datagram was submitted.
|
||||
///
|
||||
/// `wire_seq` normally matches that correlation ID after successful
|
||||
/// sends. This is best effort rather than an invariant: a kernel ID can
|
||||
/// theoretically be consumed by a send that later fails, leaving the
|
||||
/// counters desynchronized. An unmatched or implausible ID is discarded;
|
||||
/// the probe's userspace `sent_at` remains the fallback.
|
||||
/// `wire_seq` doubles as that correlation ID: both counters start at
|
||||
/// zero for a session's first timestamped send and advance by exactly
|
||||
/// one only on a confirmed successful submission, so they stay
|
||||
/// identical for the life of one open session (see the client crate's
|
||||
/// `AGENTS.md` for the full invariant).
|
||||
///
|
||||
/// Updates a still-pending or already-timed-out probe in place. Never
|
||||
/// resurrects a completed, evicted, or unknown probe, and never touches
|
||||
@@ -456,9 +443,6 @@ impl SessionMachine {
|
||||
let MachineState::Open(session) = &mut self.state else {
|
||||
return;
|
||||
};
|
||||
if !session.kernel_tx_correlation_valid {
|
||||
return;
|
||||
}
|
||||
if let Some(probe) = session.pending.get_mut(wire_seq) {
|
||||
probe.kernel_tx_timestamp.get_or_insert(timestamp);
|
||||
return;
|
||||
@@ -652,7 +636,6 @@ impl SessionMachine {
|
||||
pending: PendingMap::new(self.config.max_pending_probes),
|
||||
timed_out: TimedOutMap::new(self.config.max_pending_probes),
|
||||
completed: CompletedSet::new(self.config.max_pending_probes),
|
||||
kernel_tx_correlation_valid: true,
|
||||
}));
|
||||
|
||||
let event = ClientEvent::SessionStarted {
|
||||
@@ -1248,7 +1231,6 @@ mod tests {
|
||||
pending: PendingMap::new(max_pending_probes),
|
||||
timed_out: TimedOutMap::new(max_pending_probes),
|
||||
completed: CompletedSet::new(max_pending_probes),
|
||||
kernel_tx_correlation_valid: true,
|
||||
}));
|
||||
machine
|
||||
}
|
||||
@@ -2891,25 +2873,6 @@ 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]
|
||||
fn upstream_one_way_delay_falls_back_without_kernel_tx() {
|
||||
let mono = Instant::now();
|
||||
@@ -3232,7 +3195,6 @@ mod tests {
|
||||
pending: PendingMap::new(MAX_PENDING_PROBES),
|
||||
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
|
||||
completed: CompletedSet::new(MAX_PENDING_PROBES),
|
||||
kernel_tx_correlation_valid: true,
|
||||
}));
|
||||
(machine, params)
|
||||
}
|
||||
|
||||
@@ -60,50 +60,6 @@ pub struct TimestampFields {
|
||||
pub send_mono: Option<i64>,
|
||||
}
|
||||
|
||||
/// Encodes an ECHO reply.
|
||||
///
|
||||
/// `params` supplies the negotiated layout and length, exactly as on the
|
||||
/// request side, and every optional field the layout requires must be present
|
||||
/// in `reply`: a `Some` where the layout has no field, or a `None` where it has
|
||||
/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC`
|
||||
/// and signs the finished datagram.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when
|
||||
/// the reply does not carry exactly the packet-type flags this encoder
|
||||
/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`]
|
||||
/// when `reply`'s optional fields do not match the negotiated layout, and
|
||||
/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated
|
||||
/// length.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{
|
||||
/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats,
|
||||
/// TimestampFields, FLAG_REPLY,
|
||||
/// };
|
||||
///
|
||||
/// // `received_stats` negotiates a receive counter; four payload bytes.
|
||||
/// let params = Params {
|
||||
/// length: 24,
|
||||
/// received_stats: ReceivedStats::Count,
|
||||
/// ..Params::default()
|
||||
/// };
|
||||
/// let reply = EchoReply {
|
||||
/// flags: FLAG_REPLY,
|
||||
/// token: 0x1234_5678_9abc_def0,
|
||||
/// sequence: 7,
|
||||
/// recv_count: Some(3),
|
||||
/// recv_window: None,
|
||||
/// timestamps: TimestampFields::default(),
|
||||
/// payload: vec![1, 2, 3, 4],
|
||||
/// };
|
||||
///
|
||||
/// let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
||||
/// assert_eq!(decode_echo_reply(&packet, ¶ms, None).unwrap(), reply);
|
||||
/// ```
|
||||
pub fn encode_echo_reply(
|
||||
reply: &EchoReply,
|
||||
params: &Params,
|
||||
|
||||
@@ -68,23 +68,6 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()>
|
||||
/// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and
|
||||
/// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never
|
||||
/// modified.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode};
|
||||
///
|
||||
/// // Synthetic example key — use a real shared secret in production.
|
||||
/// let key = b"example key";
|
||||
/// let params = Params::with_protocol_defaults();
|
||||
/// let packet = encode_request(
|
||||
/// RequestToEncode::Open { params: ¶ms, no_test: false },
|
||||
/// Some(key),
|
||||
/// ).unwrap();
|
||||
///
|
||||
/// verify_packet_hmac(key, &packet).unwrap();
|
||||
/// assert!(verify_packet_hmac(b"wrong key", &packet).is_err());
|
||||
/// ```
|
||||
pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
|
||||
let envelope = envelope::decode_structural(packet)?;
|
||||
if !envelope.hmac_present {
|
||||
|
||||
@@ -135,20 +135,6 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize {
|
||||
/// A ceiling on what a negotiated length may legitimately *be* — an MTU, a
|
||||
/// resource bound, a maximum packet size — is deliberately not here. That is
|
||||
/// server and runtime policy, and it belongs where the negotiation happens.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{echo_packet_len, Params};
|
||||
///
|
||||
/// // The default layout's mandatory field block is 16 bytes (header, token,
|
||||
/// // sequence), so a smaller requested length is floored up to it.
|
||||
/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16);
|
||||
/// assert_eq!(
|
||||
/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(),
|
||||
/// 40,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
|
||||
let header_len = echo_header_len(hmac, params);
|
||||
let requested = if params.length <= 0 {
|
||||
|
||||
@@ -42,39 +42,6 @@
|
||||
//! which reply kind to expect, and the two need genuinely different semantic
|
||||
//! context.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! The normal path is directional: a sender encodes with [`encode_request`], a
|
||||
//! receiver classifies with [`decode_request`], and authentication is a
|
||||
//! separate step once the applicable key is known.
|
||||
//!
|
||||
//! ```
|
||||
//! use irtt_proto::{
|
||||
//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params,
|
||||
//! RequestToEncode,
|
||||
//! };
|
||||
//!
|
||||
//! // Sender side: encode an authenticated open request.
|
||||
//! let params = Params::with_protocol_defaults();
|
||||
//! let key = b"example key";
|
||||
//! let packet = encode_request(
|
||||
//! RequestToEncode::Open { params: ¶ms, no_test: false },
|
||||
//! Some(key),
|
||||
//! ).unwrap();
|
||||
//!
|
||||
//! // Receiver side: classify structurally, before any key is known.
|
||||
//! let request = decode_request(&packet).unwrap();
|
||||
//! assert!(request.hmac_present);
|
||||
//! let DecodedRequestKind::Open { no_test, params: encoded } = request.kind else {
|
||||
//! panic!("expected an open request");
|
||||
//! };
|
||||
//! assert!(!no_test);
|
||||
//! assert_eq!(Params::decode(encoded).unwrap(), params);
|
||||
//!
|
||||
//! // Authentication is a separate step, once the applicable key is known.
|
||||
//! verify_packet_hmac(key, &packet).unwrap();
|
||||
//! ```
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod echo;
|
||||
|
||||
@@ -13,34 +13,6 @@ pub struct OpenReply {
|
||||
pub params: Params,
|
||||
}
|
||||
|
||||
/// Encodes an open reply.
|
||||
///
|
||||
/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the
|
||||
/// finished datagram, while `None` produces an unauthenticated reply and clears
|
||||
/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply
|
||||
/// also carries `FLAG_CLOSE` (a rejected open).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or
|
||||
/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without
|
||||
/// `FLAG_CLOSE`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{
|
||||
/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY,
|
||||
/// };
|
||||
///
|
||||
/// let reply = OpenReply {
|
||||
/// flags: FLAG_OPEN | FLAG_REPLY,
|
||||
/// token: 0x1234_5678_9abc_def0,
|
||||
/// params: Params::with_protocol_defaults(),
|
||||
/// };
|
||||
/// let packet = encode_open_reply(&reply, None).unwrap();
|
||||
/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply);
|
||||
/// ```
|
||||
pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
let params = reply.params.encode();
|
||||
let mut out = envelope::begin_checked(
|
||||
@@ -58,18 +30,6 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<V
|
||||
envelope::finish(out, hmac_key)
|
||||
}
|
||||
|
||||
/// Decodes an open reply.
|
||||
///
|
||||
/// Requires `FLAG_OPEN` and `FLAG_REPLY`, and — when `hmac_key` is supplied —
|
||||
/// verifies the authentication field. The decoded [`Params`] are validated:
|
||||
/// malformed or incompatible parameter values are rejected here.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ProtoError::MissingFlag`], [`ProtoError::HmacPresenceMismatch`],
|
||||
/// [`ProtoError::BadHmac`], [`ProtoError::PacketTooShort`],
|
||||
/// [`ProtoError::ZeroToken`], or a parameter decode error. See
|
||||
/// [`encode_open_reply`] for a round-trip example.
|
||||
pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> {
|
||||
let envelope = envelope::decode(
|
||||
packet,
|
||||
|
||||
@@ -16,29 +16,6 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
|
||||
/// request with an empty parameter payload means. It is not a set of sensible
|
||||
/// client settings; a client builds its request from its own configuration.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Construct a `Params`, encode it, and decode it back:
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
///
|
||||
/// let params = Params {
|
||||
/// protocol_version: 1,
|
||||
/// duration_ns: 3_000_000_000,
|
||||
/// interval_ns: 1_000_000_000,
|
||||
/// length: 1472,
|
||||
/// received_stats: ReceivedStats::Both,
|
||||
/// stamp_at: StampAt::Both,
|
||||
/// clock: Clock::Both,
|
||||
/// server_fill: Some(ServerFill { value: "rand".to_owned() }),
|
||||
/// ..Params::default()
|
||||
/// };
|
||||
///
|
||||
/// let encoded = params.encode();
|
||||
/// assert_eq!(Params::decode(&encoded).unwrap(), params);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Params {
|
||||
pub protocol_version: i64,
|
||||
@@ -111,20 +88,6 @@ impl Params {
|
||||
/// Presence means the tag appeared at least once. Repeated known tags keep
|
||||
/// last-value-wins, and unknown tags remain ignored and untracked.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::Params;
|
||||
///
|
||||
/// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval
|
||||
/// // (tag 3) is absent, so both fields decode to zero but only one is
|
||||
/// // reported present.
|
||||
/// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap();
|
||||
/// assert_eq!(decoded.params.duration_ns, 0);
|
||||
/// assert!(decoded.presence.duration_ns);
|
||||
/// assert!(!decoded.presence.interval_ns);
|
||||
/// ```
|
||||
///
|
||||
/// [`decode`]: Params::decode
|
||||
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
|
||||
let mut params = Self::default();
|
||||
|
||||
@@ -88,26 +88,6 @@ pub enum DecodedRequestKind<'a> {
|
||||
/// the negotiated length is positive and wider than `usize`. A *negative*
|
||||
/// negotiated length is not an error: see [`echo_packet_len`], which floors it
|
||||
/// at the mandatory field block.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{encode_request, Params, RequestToEncode};
|
||||
///
|
||||
/// // An unauthenticated ECHO probe. `params` supplies the negotiated length,
|
||||
/// // which reserves four payload bytes beyond the 16-byte echo header.
|
||||
/// let params = Params { length: 20, ..Params::default() };
|
||||
/// let packet = encode_request(
|
||||
/// RequestToEncode::Echo {
|
||||
/// token: 0x1234_5678_9abc_def0,
|
||||
/// sequence: 7,
|
||||
/// params: ¶ms,
|
||||
/// payload: &[1, 2, 3, 4],
|
||||
/// },
|
||||
/// None,
|
||||
/// ).unwrap();
|
||||
/// assert_eq!(packet.len(), 20);
|
||||
/// ```
|
||||
pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
match request {
|
||||
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key),
|
||||
@@ -158,21 +138,6 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) ->
|
||||
/// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
|
||||
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
|
||||
/// `FLAG_REPLY`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode};
|
||||
///
|
||||
/// let packet = encode_request(
|
||||
/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 },
|
||||
/// None,
|
||||
/// ).unwrap();
|
||||
///
|
||||
/// let request = decode_request(&packet).unwrap();
|
||||
/// assert!(!request.hmac_present);
|
||||
/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 });
|
||||
/// ```
|
||||
pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
|
||||
let envelope = envelope::decode_structural(packet)?;
|
||||
if has(envelope.flags, FLAG_REPLY) {
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
//! Variable-length integer encoding for IRTT wire values.
|
||||
//!
|
||||
//! Tags use an unsigned base-128 varint ([`encode_uvarint`]), and signed
|
||||
//! integers (such as parameter values) use the same encoding after a zigzag
|
||||
//! transform ([`encode_varint`]), which keeps small negative numbers compact.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```
|
||||
//! use irtt_proto::varint::{decode_varint, encode_varint};
|
||||
//!
|
||||
//! let mut out = Vec::new();
|
||||
//! encode_varint(1_000_000_000, &mut out);
|
||||
//! assert_eq!(decode_varint(&out).unwrap(), (1_000_000_000, out.len()));
|
||||
//! ```
|
||||
|
||||
use crate::{ProtoError, Result};
|
||||
|
||||
pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) {
|
||||
|
||||
@@ -500,14 +500,13 @@ session policy lives.
|
||||
unmarked reply into a *dropped* one, because an unappliable marking stops the
|
||||
send. Verify a platform before extending that list.
|
||||
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
|
||||
replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers
|
||||
matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT`
|
||||
listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that
|
||||
pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not
|
||||
weaken the rule that an unappliable marking stops the send. The crate forbids
|
||||
unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery.
|
||||
Keep the target lists identical to the pinned `socket2` version's own gates
|
||||
— check its source rather than copying another crate's older matrix.
|
||||
replies, and this is known. It is not fixed by choosing the option per reply,
|
||||
for the macOS reason above, and it predates wildcard source selection. Leave it
|
||||
until it is worth its own slice, and do not "fix" it by weakening the rule that
|
||||
an unappliable marking stops the send. The crate forbids unsafe code;
|
||||
there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. 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
|
||||
|
||||
|
||||
@@ -98,28 +98,6 @@ pub enum TimestampAllowance {
|
||||
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and
|
||||
/// `none` is zero-filled here rather than left as residue. An operator
|
||||
/// restriction can be added if a real use case turns up.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Configure a modest per-listener session budget and restrict timestamp
|
||||
/// placement while leaving DSCP marking disabled:
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use irtt_server::{ServerConfig, TimestampAllowance};
|
||||
///
|
||||
/// let config = ServerConfig::default()
|
||||
/// .with_max_sessions(128)
|
||||
/// .with_idle_timeout(Duration::from_secs(30))
|
||||
/// .with_timestamp_allowance(TimestampAllowance::Single)
|
||||
/// .with_dscp_allowed(false);
|
||||
///
|
||||
/// assert_eq!(config.max_sessions(), 128);
|
||||
/// assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
/// assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
/// assert!(!config.dscp_allowed());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfig {
|
||||
hmac_key: Option<Vec<u8>>,
|
||||
|
||||
@@ -47,32 +47,6 @@ use crate::{
|
||||
/// a transport abstraction, and there is no blocking or alternate-runtime
|
||||
/// counterpart.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// The core can be driven directly with a normally encoded open request. It
|
||||
/// returns a reply to send and records the new session without performing I/O:
|
||||
///
|
||||
/// ```
|
||||
/// use std::net::{Ipv4Addr, SocketAddr};
|
||||
///
|
||||
/// use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
|
||||
/// use irtt_server::{ServerConfig, ServerCore};
|
||||
///
|
||||
/// let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 40_000));
|
||||
/// let mut core = ServerCore::new(ServerConfig::default());
|
||||
/// let params = Params::with_protocol_defaults();
|
||||
/// let request = encode_request(
|
||||
/// RequestToEncode::Open { params: ¶ms, no_test: false },
|
||||
/// None,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
///
|
||||
/// let reply = core.handle_datagram(peer, &request).unwrap().unwrap();
|
||||
/// let open = decode_open_reply(reply.bytes(), None).unwrap();
|
||||
/// assert_ne!(open.token, 0);
|
||||
/// assert_eq!(core.session_count(), 1);
|
||||
/// ```
|
||||
///
|
||||
/// # Scope
|
||||
///
|
||||
/// The core implements open handling, session creation and negotiation,
|
||||
|
||||
@@ -53,28 +53,6 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than
|
||||
/// served by a listener whose replies a client on a second address would
|
||||
/// silently discard. Explicit-address listeners are unaffected everywhere.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Bind one listener, inspect the resolved endpoint, and let the caller decide
|
||||
/// when serving ends. This example type-checks without opening a socket:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
|
||||
///
|
||||
/// use irtt_server::{Server, ServerConfig};
|
||||
///
|
||||
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut server = Server::bind(
|
||||
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
/// ServerConfig::default(),
|
||||
/// )
|
||||
/// .await?;
|
||||
/// println!("listening on {}", server.local_addr()?);
|
||||
/// server.run(tokio::time::sleep(Duration::from_secs(60))).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Server {
|
||||
socket: UdpSocket,
|
||||
|
||||
@@ -57,29 +57,6 @@ use crate::{Server, ServerConfig, ServerRuntimeError};
|
||||
/// whole set: a service configured for IPv4 and IPv6 must not quietly continue
|
||||
/// as IPv4 only. Every listener task is joined before `run` returns, so no task
|
||||
/// outlives it.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Bind independent listeners, inspect their resolved addresses, then serve
|
||||
/// them under one caller-owned shutdown future. This example type-checks
|
||||
/// without binding sockets:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
|
||||
///
|
||||
/// use irtt_server::{ServerConfig, ServerSet};
|
||||
///
|
||||
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let addrs = [
|
||||
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
/// ];
|
||||
/// let set = ServerSet::bind(addrs, ServerConfig::default()).await?;
|
||||
/// println!("listening on {:?}", set.local_addrs());
|
||||
/// set.run(tokio::time::sleep(Duration::from_secs(60))).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct ServerSet {
|
||||
servers: Vec<Server>,
|
||||
@@ -114,14 +91,6 @@ impl ServerSet {
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
///
|
||||
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
|
||||
|
||||
@@ -66,44 +66,6 @@ impl StatsConfig {
|
||||
/// them reports a median in the cumulative snapshot once it has samples,
|
||||
/// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
|
||||
/// replies can still complete IPDV pairs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_stats::{StatsCollector, StatsConfig};
|
||||
///
|
||||
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
/// # use std::time::{Duration, Instant, SystemTime};
|
||||
/// # use irtt_client::{ClientEvent, ClientTimestamp};
|
||||
/// #
|
||||
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
|
||||
/// # let sent_at = ClientTimestamp {
|
||||
/// # mono: Instant::now() + Duration::from_millis(at_ms),
|
||||
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
|
||||
/// # };
|
||||
/// # ClientEvent::EchoSent {
|
||||
/// # seq,
|
||||
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
|
||||
/// # scheduled_at: sent_at.mono,
|
||||
/// # sent_at,
|
||||
/// # bytes: 64,
|
||||
/// # send_call: Duration::from_micros(send_call_us),
|
||||
/// # timer_error: Duration::from_micros(2),
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
/// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
|
||||
/// collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us));
|
||||
/// }
|
||||
///
|
||||
/// let snapshot = collector.snapshot();
|
||||
/// // Finite mode retains exact samples, so the send-call metric reports an
|
||||
/// // exact median once it has samples: the middle of the sorted
|
||||
/// // 10, 20, 30 µs values, not their mean.
|
||||
/// assert_eq!(snapshot.send_call.count, 3);
|
||||
/// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
|
||||
/// ```
|
||||
pub fn finite() -> Self {
|
||||
Self {
|
||||
samples: SampleMode::Exact,
|
||||
@@ -118,44 +80,6 @@ impl StatsConfig {
|
||||
/// Continuous mode uses running statistics, retains no exact samples so no
|
||||
/// timing metric reports a median, and bounds adjacent-sequence IPDV
|
||||
/// tracking for long-running sessions.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_stats::{StatsCollector, StatsConfig};
|
||||
///
|
||||
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
/// # use std::time::{Duration, Instant, SystemTime};
|
||||
/// # use irtt_client::{ClientEvent, ClientTimestamp};
|
||||
/// #
|
||||
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
|
||||
/// # let sent_at = ClientTimestamp {
|
||||
/// # mono: Instant::now() + Duration::from_millis(at_ms),
|
||||
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
|
||||
/// # };
|
||||
/// # ClientEvent::EchoSent {
|
||||
/// # seq,
|
||||
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
|
||||
/// # scheduled_at: sent_at.mono,
|
||||
/// # sent_at,
|
||||
/// # bytes: 64,
|
||||
/// # send_call: Duration::from_micros(send_call_us),
|
||||
/// # timer_error: Duration::from_micros(2),
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// let mut collector = StatsCollector::new(StatsConfig::continuous());
|
||||
/// for seq in 0..3 {
|
||||
/// collector.process(&sent(seq, u64::from(seq) * 10, 10));
|
||||
/// }
|
||||
///
|
||||
/// let snapshot = collector.snapshot();
|
||||
/// // Continuous mode still tracks running statistics — count, mean, min,
|
||||
/// // and max — but retains no exact samples, so it reports no median.
|
||||
/// assert_eq!(snapshot.send_call.count, 3);
|
||||
/// assert!(snapshot.send_call.mean_ns > 0.0);
|
||||
/// assert_eq!(snapshot.send_call.median_ns, None);
|
||||
/// ```
|
||||
pub fn continuous() -> Self {
|
||||
Self {
|
||||
samples: SampleMode::RunningOnly,
|
||||
@@ -211,34 +135,6 @@ impl StatsConfig {
|
||||
/// The result is deterministic and computed with saturating arithmetic, so
|
||||
/// an enormous `probe_count` saturates at [`u64::MAX`] rather than
|
||||
/// wrapping or panicking.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_stats::StatsConfig;
|
||||
///
|
||||
/// // Finite retention grows with the probe count...
|
||||
/// let finite = StatsConfig::finite();
|
||||
/// assert!(
|
||||
/// finite.estimated_retained_bytes(1_000_000)
|
||||
/// > finite.estimated_retained_bytes(1_000)
|
||||
/// );
|
||||
///
|
||||
/// // ...while continuous retention is bounded, so a far longer run is no
|
||||
/// // more expensive.
|
||||
/// let continuous = StatsConfig::continuous();
|
||||
/// assert_eq!(
|
||||
/// continuous.estimated_retained_bytes(1_000_000),
|
||||
/// continuous.estimated_retained_bytes(10_000_000),
|
||||
/// );
|
||||
/// assert!(
|
||||
/// continuous.estimated_retained_bytes(1_000_000)
|
||||
/// < finite.estimated_retained_bytes(1_000_000)
|
||||
/// );
|
||||
///
|
||||
/// // Enormous probe counts saturate rather than wrapping or panicking.
|
||||
/// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX);
|
||||
/// ```
|
||||
pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
|
||||
retention::estimated_retained_bytes(self, probe_count)
|
||||
}
|
||||
@@ -296,60 +192,6 @@ pub enum SampleMode {
|
||||
///
|
||||
/// A collector maintains cumulative statistics and, when configured, rolling
|
||||
/// windows. Rolling snapshots are recomputed from retained normalized events.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Feed a short stream of `irtt-client` events to a finite-mode collector and
|
||||
/// read back a cumulative snapshot:
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_stats::{StatsCollector, StatsConfig};
|
||||
///
|
||||
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
/// # use std::time::{Duration, Instant, SystemTime};
|
||||
/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration};
|
||||
/// #
|
||||
/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s.
|
||||
/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent {
|
||||
/// # let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
|
||||
/// # let sent_at = ClientTimestamp {
|
||||
/// # mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10),
|
||||
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10),
|
||||
/// # };
|
||||
/// # let raw = Duration::from_millis(rtt_ms);
|
||||
/// # ClientEvent::EchoReply {
|
||||
/// # seq,
|
||||
/// # remote,
|
||||
/// # sent_at,
|
||||
/// # received_at: ClientTimestamp {
|
||||
/// # mono: sent_at.mono + raw,
|
||||
/// # wall: sent_at.wall + raw,
|
||||
/// # },
|
||||
/// # rtt: RttSample {
|
||||
/// # raw,
|
||||
/// # adjusted: None,
|
||||
/// # effective: SignedDuration::from_duration(raw),
|
||||
/// # },
|
||||
/// # server_timing: None,
|
||||
/// # one_way: None,
|
||||
/// # received_stats: None,
|
||||
/// # bytes: 64,
|
||||
/// # packet_meta: irtt_client::PacketMeta::default(),
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() {
|
||||
/// collector.process(&reply(seq as u32, rtt_ms));
|
||||
/// }
|
||||
///
|
||||
/// let snapshot = collector.snapshot();
|
||||
/// assert_eq!(snapshot.events.echo_replies, 3);
|
||||
/// assert_eq!(snapshot.rtt.primary.count, 3);
|
||||
/// // Finite mode retains exact samples, so the round-trip metric reports an
|
||||
/// // exact median: the middle of the sorted 10, 20, 30 ms samples.
|
||||
/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0));
|
||||
/// ```
|
||||
pub struct StatsCollector {
|
||||
cumulative: CoreStats,
|
||||
rolling: RollingEvents,
|
||||
@@ -394,53 +236,6 @@ impl StatsCollector {
|
||||
/// running statistics only, whatever [`StatsConfig::samples`] is set to,
|
||||
/// so its timing metrics report no median. A rolling window is a bounded
|
||||
/// recent view rather than the run's retained history.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_stats::{StatsCollector, StatsConfig};
|
||||
///
|
||||
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
/// # use std::time::{Duration, Instant, SystemTime};
|
||||
/// # use irtt_client::{ClientEvent, ClientTimestamp};
|
||||
/// #
|
||||
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
|
||||
/// # let sent_at = ClientTimestamp {
|
||||
/// # mono: Instant::now() + Duration::from_millis(at_ms),
|
||||
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
|
||||
/// # };
|
||||
/// # ClientEvent::EchoSent {
|
||||
/// # seq,
|
||||
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
|
||||
/// # scheduled_at: sent_at.mono,
|
||||
/// # sent_at,
|
||||
/// # bytes: 64,
|
||||
/// # send_call: Duration::from_micros(send_call_us),
|
||||
/// # timer_error: Duration::from_micros(2),
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// let mut collector = StatsCollector::new(StatsConfig {
|
||||
/// rolling_count: Some(2),
|
||||
/// ..StatsConfig::finite()
|
||||
/// });
|
||||
/// collector.process(&sent(0, 0, 10));
|
||||
/// collector.process(&sent(1, 10, 10));
|
||||
/// collector.process(&sent(2, 20, 10));
|
||||
///
|
||||
/// // The window keeps only the last two normalized events...
|
||||
/// let rolling = collector.rolling_count().unwrap();
|
||||
/// assert_eq!(rolling.packets.packets_sent, 2);
|
||||
/// // ...and is recomputed with running statistics, so it reports no median
|
||||
/// // even though the collector retains exact samples for its cumulative
|
||||
/// // snapshot.
|
||||
/// assert_eq!(rolling.send_call.median_ns, None);
|
||||
///
|
||||
/// // The cumulative snapshot still sees every event.
|
||||
/// let cumulative = collector.snapshot();
|
||||
/// assert_eq!(cumulative.packets.packets_sent, 3);
|
||||
/// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0));
|
||||
/// ```
|
||||
pub fn rolling_count(&self) -> Option<Snapshot> {
|
||||
self.rolling.count_snapshot()
|
||||
}
|
||||
@@ -449,47 +244,6 @@ impl StatsCollector {
|
||||
///
|
||||
/// Like [`StatsCollector::rolling_count`], this is recomputed with running
|
||||
/// statistics only and reports no medians.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use irtt_stats::{StatsCollector, StatsConfig};
|
||||
///
|
||||
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
/// # use std::time::Instant;
|
||||
/// # use irtt_client::{ClientEvent, ClientTimestamp};
|
||||
/// #
|
||||
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
|
||||
/// # let sent_at = ClientTimestamp {
|
||||
/// # mono: Instant::now() + Duration::from_millis(at_ms),
|
||||
/// # wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
|
||||
/// # };
|
||||
/// # ClientEvent::EchoSent {
|
||||
/// # seq,
|
||||
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
|
||||
/// # scheduled_at: sent_at.mono,
|
||||
/// # sent_at,
|
||||
/// # bytes: 64,
|
||||
/// # send_call: Duration::from_micros(send_call_us),
|
||||
/// # timer_error: Duration::from_micros(2),
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// let mut collector = StatsCollector::new(StatsConfig {
|
||||
/// rolling_time: Some(Duration::from_millis(15)),
|
||||
/// ..StatsConfig::finite()
|
||||
/// });
|
||||
/// collector.process(&sent(0, 0, 10));
|
||||
/// collector.process(&sent(1, 10, 10));
|
||||
/// collector.process(&sent(2, 30, 10));
|
||||
///
|
||||
/// // A 15 ms window anchored at the latest event (30 ms) retains only the
|
||||
/// // events from the last 15 ms — here just the final send.
|
||||
/// let rolling = collector.rolling_time().unwrap();
|
||||
/// assert_eq!(rolling.packets.packets_sent, 1);
|
||||
/// ```
|
||||
pub fn rolling_time(&self) -> Option<Snapshot> {
|
||||
self.rolling.time_snapshot()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,26 @@ use irtt_stats::{
|
||||
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn exact_mode_reports_a_send_call_median() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
|
||||
collector.process(&sent_with_timings(
|
||||
seq as u32,
|
||||
ts(seq as u64 * 10),
|
||||
send_call_us,
|
||||
2,
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = collector.snapshot();
|
||||
assert_eq!(snapshot.send_call.count, 3);
|
||||
assert_eq!(snapshot.send_call.min_ns, Some(10_000));
|
||||
assert_eq!(snapshot.send_call.max_ns, Some(30_000));
|
||||
// Sorted 10, 20, 30 us: the median is the middle sample, not the mean.
|
||||
assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_mode_reports_a_timer_error_median() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
|
||||
@@ -139,5 +139,5 @@ scenario contains the existing comparison harness output under
|
||||
`netem-summary.json` files. Server stdout/stderr are captured at the run root as
|
||||
`server.stdout` and `server.stderr`.
|
||||
|
||||
On macOS, `ip netns` and `tc netem` are unavailable; use the Gitea Actions
|
||||
On macOS, `ip netns` and `tc netem` are unavailable; use the GitHub Actions
|
||||
workflow for netem coverage.
|
||||
+7
-85
@@ -6,7 +6,7 @@ irtt-client - IRTT-compatible stream client
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
|
||||
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]...
|
||||
|
||||
`irtt-client` `--list-columns`
|
||||
|
||||
@@ -18,39 +18,17 @@ eligible runs.
|
||||
|
||||
## TARGETS
|
||||
|
||||
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
|
||||
`LABEL=` prefix assigns the logical target name used in output; without it,
|
||||
the target string itself is used as the label. Labeled and unlabeled targets
|
||||
can be freely mixed in one argument list. Prefer explicit labels for
|
||||
long-running dynamic target sets: the label is the stable target identity
|
||||
across replacements.
|
||||
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix
|
||||
assigns the logical target name used in output; without it, the target
|
||||
string itself is used as the label. Labeled and unlabeled targets can be
|
||||
freely mixed in one argument list.
|
||||
|
||||
```sh
|
||||
irtt-client host-a:2112
|
||||
irtt-client eu=host-a:2112 us=host-b:2112
|
||||
irtt-client host-a:2112 eu=host-b:2112
|
||||
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
|
||||
```
|
||||
|
||||
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
|
||||
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
|
||||
target. The modifier uses `@`, not a shell command separator; quote an argument
|
||||
if its key needs shell quoting.
|
||||
|
||||
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
||||
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||
label or address when it would otherwise begin `@hmac=`; escaping the first
|
||||
`=` is how an unlabeled target or a label can contain that character. An `=` in
|
||||
an HMAC value, including Base64 padding, does not need escaping. Positional
|
||||
target arguments may contain a raw comma; stdin target sets have the additional
|
||||
`\,` escape described below.
|
||||
|
||||
Shells process unquoted backslashes and characters such as semicolons before
|
||||
the client sees them. Single-quote a target when it contains parser escapes,
|
||||
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
|
||||
backslashes reach `irtt-client` unchanged.
|
||||
|
||||
Multiple targets are probed concurrently. By default, a multi-target session
|
||||
uses **staggered** pacing, spacing active targets across the probe interval;
|
||||
pass `--pacing burst` to send one probe to every active target back-to-back
|
||||
@@ -58,57 +36,6 @@ once per interval instead. Final per-target summaries print in the order
|
||||
targets were supplied on the command line, not the order in which they
|
||||
finish or their labels sort alphabetically.
|
||||
|
||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||
|
||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||
Positional targets remain an initial desired set, and may be omitted to
|
||||
start empty:
|
||||
|
||||
~~~sh
|
||||
irtt-client --duration 0 --targets-stdin
|
||||
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
|
||||
~~~
|
||||
|
||||
Each non-empty stdin record declares the complete desired target set, rather
|
||||
than a delta. A later record replaces an earlier set: targets absent from it
|
||||
are retired, unchanged target configurations retain their generation, and a
|
||||
changed address or HMAC setting creates a fresh generation. Under transient
|
||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||
|
||||
Commas frame targets in stdin records; escape a literal comma within one
|
||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
|
||||
client is being controlled. For example, in one terminal:
|
||||
|
||||
~~~sh
|
||||
mkfifo /tmp/irtt-targets
|
||||
irtt-client --duration 0 --targets-stdin --hmac default-secret \
|
||||
< /tmp/irtt-targets
|
||||
~~~
|
||||
|
||||
Then open the FIFO once from another terminal and keep that descriptor open
|
||||
while publishing complete desired sets:
|
||||
|
||||
~~~sh
|
||||
exec 3> /tmp/irtt-targets
|
||||
printf '%s\n' 'ams=ams.example:2112,sg=sg.example:2112@hmac=sg-secret' >&3
|
||||
printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
|
||||
# Publish more sets as needed, then send EOF and stop the client:
|
||||
exec 3>&-
|
||||
~~~
|
||||
|
||||
The exact record `[]` selects an empty desired set. Empty records are
|
||||
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
|
||||
or closure of the control plane and requests a graceful stop. A desired-state
|
||||
revision that was read but has not yet been acknowledged as applied may be
|
||||
discarded when EOF is observed; EOF does not wait for a capacity-rejected
|
||||
revision to become applicable.
|
||||
|
||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||
desired set to 128 targets and retains at most 256 live target generations
|
||||
while replacements drain.
|
||||
|
||||
## FINITE VERSUS CONTINUOUS OPERATION
|
||||
|
||||
`--duration` controls the run length:
|
||||
@@ -118,9 +45,8 @@ while replacements drain.
|
||||
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
|
||||
- `--duration 0` is **continuous**: the client runs until interrupted
|
||||
(`Ctrl-C`). For table output, a final summary is printed only if the run
|
||||
was interrupted. Stdin-controlled mode is the exception: EOF also requests
|
||||
a graceful stop and prints the final retained summary. An uninterrupted
|
||||
continuous run that exits for another reason does not get one.
|
||||
was interrupted; an uninterrupted continuous run (one that exits for
|
||||
another reason) does not get one.
|
||||
|
||||
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
||||
distinction also determines what statistics can be computed and how much
|
||||
@@ -266,10 +192,6 @@ the client exits nonzero — so a process supervisor can restart it; an
|
||||
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
||||
mode it was in.
|
||||
|
||||
With `--targets-stdin`, peer closure is target-local: other desired targets
|
||||
and the stdin controller continue running. Including that target in a later
|
||||
complete set starts a fresh generation.
|
||||
|
||||
## EXIT STATUS
|
||||
|
||||
`0` on success (including a finite run's ordinary completion, and an
|
||||
|
||||
+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.
|
||||
|
||||
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
|
||||
one of the recognized applet names — `irtt-client`, `irtt-tui`, or
|
||||
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
|
||||
`irtt-server` — it runs that applet directly, without needing a subcommand.
|
||||
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
|
||||
rejected as an unknown applet name before subcommand parsing is even
|
||||
|
||||
+1
-40
@@ -60,17 +60,7 @@ served.
|
||||
A port of `0` selects an unused port per listener, so two such binds get two
|
||||
different ports.
|
||||
|
||||
With no `--bind` at all, see [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.
|
||||
With no `--bind` at all, see [Default bind](#default-bind) above.
|
||||
|
||||
### `--hmac KEY`
|
||||
|
||||
@@ -146,35 +136,6 @@ and they are sent unmarked. The session is not refused.
|
||||
Durations everywhere in this command accept an integer followed by a unit:
|
||||
`ms`, `s`, or `m`.
|
||||
|
||||
## 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
|
||||
|
||||
Each bound endpoint is printed on startup, once every listener is up, which
|
||||
|
||||
@@ -62,14 +62,6 @@ 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
|
||||
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
|
||||
|
||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
||||
|
||||
Generated
+6
-133
@@ -8,18 +8,6 @@ version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
@@ -31,9 +19,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.5"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -47,12 +35,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -97,9 +79,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
@@ -144,24 +126,10 @@ dependencies = [
|
||||
name = "irtt-proto-fuzz"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"libfuzzer-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-server"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"irtt-proto",
|
||||
"nix",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
@@ -198,45 +166,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -267,16 +196,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -285,9 +204,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -314,31 +233,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -350,24 +244,3 @@ name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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",
|
||||
]
|
||||
+1
-12
@@ -8,15 +8,11 @@ edition = "2021"
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
arbitrary = "1.4.2"
|
||||
libfuzzer-sys = "0.4.13"
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[dependencies.irtt-proto]
|
||||
path = "../crates/irtt-proto"
|
||||
|
||||
[dependencies.irtt-server]
|
||||
path = "../crates/irtt-server"
|
||||
|
||||
# Use independent workspace for fuzzers
|
||||
[workspace]
|
||||
members = ["."]
|
||||
@@ -55,10 +51,3 @@ path = "fuzz_targets/decode_varint.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "server_core_datagrams"
|
||||
path = "fuzz_targets/server_core_datagrams.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
+6
-9
@@ -1,7 +1,6 @@
|
||||
# `irtt-rs` fuzzing
|
||||
# `irtt-proto` fuzzing
|
||||
|
||||
Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public
|
||||
`ServerCore::handle_datagram` API via
|
||||
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
|
||||
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
|
||||
|
||||
This project is intentionally isolated from the normal stable/MSRV
|
||||
@@ -23,16 +22,14 @@ 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_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` |
|
||||
| `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**.
|
||||
Returning `Err` is normal and expected; a successful decode is also normal.
|
||||
|
||||
Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer
|
||||
spends its time exploring protocol structure rather than allocating/copying
|
||||
megabytes. `server_core_datagrams` additionally caps its sequence length and
|
||||
each individual datagram and configures the core to the same packet cap, so one
|
||||
libFuzzer input cannot become a tiny DoS benchmark.
|
||||
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
|
||||
libFuzzer spends its time exploring protocol structure rather than
|
||||
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
|
||||
under this bound.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
#![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