Compare commits

..
Author SHA1 Message Date
renovate Bot 4104a1100a chore(deps): update github actions 2026-08-29 14:39:13 +00:00
73 changed files with 765 additions and 3359 deletions

No files matched your search

+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
# (native builds, no emulation), but buildx still needs a driver that # (native builds, no emulation), but buildx still needs a driver that
# can assemble a multi-platform manifest list. # can assemble a multi-platform manifest list.
- name: Set up Docker Buildx - 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 - name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -76,7 +76,7 @@ jobs:
# below), never a separate build, so the hash matches everywhere. # below), never a separate build, so the hash matches everywhere.
- name: Build and push to Gitea - name: Build and push to Gitea
id: build id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
-52
View File
@@ -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
-109
View File
@@ -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
+29 -7
View File
@@ -16,6 +16,34 @@ permissions:
contents: read contents: read
jobs: jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/irtt-rs-release.yml
shellcheck: false
zizmor: zizmor:
name: zizmor name: zizmor
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -32,13 +60,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
# It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set.
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with: with:
persona: regular persona: regular
inputs: |
.github/workflows
.gitea/workflows
-80
View File
@@ -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: pull_request:
branches: branches:
- main - main
merge_group:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -28,10 +29,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: rustfmt components: rustfmt
cache: false cache-key: fmt
- name: Check formatting - name: Check formatting
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1 uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
@@ -48,10 +49,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: clippy components: clippy
cache: false cache-key: clippy
- name: Run clippy - name: Run clippy
run: | run: |
@@ -78,22 +79,46 @@ jobs:
matrix: matrix:
include: include:
- task: check - task: check
runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
musl: false musl: false
- task: test - task: test
runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
musl: false musl: false
- task: check - task: check
runner: ubuntu-24.04
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
musl: true musl: true
- task: test - task: test
runner: ubuntu-24.04
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
musl: true musl: true
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 timeout-minutes: 25
steps: steps:
@@ -109,10 +134,10 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
cache: false cache-key: ${{ matrix.task }}-${{ matrix.target }}
- name: Run ${{ matrix.task }} with all features - name: Run ${{ matrix.task }} with all features
run: | run: |
@@ -147,9 +172,9 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache: false cache-key: docs
- name: Check docs - name: Check docs
run: | run: |
@@ -158,6 +183,3 @@ jobs:
--all-features \ --all-features \
--no-deps \ --no-deps \
--locked --locked
- name: Run doctests
run: cargo test --workspace --doc --all-features --locked
@@ -1,4 +1,4 @@
name: Fuzz Smoke name: Proto Fuzz Smoke
on: on:
push: push:
@@ -16,9 +16,6 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`. # Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
CARGO_FUZZ_VERSION: 0.13.1 CARGO_FUZZ_VERSION: 0.13.1
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
FUZZ_TARGET: x86_64-unknown-linux-gnu
jobs: jobs:
fuzz-smoke: fuzz-smoke:
@@ -33,35 +30,24 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install stable Rust toolchain - name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache: false cache-key: fuzz
- name: Install nightly Rust toolchain - name: Install nightly Rust toolchain
# Installed alongside, but deliberately not made the directory # Installed alongside, but deliberately not made the directory
# default: cargo-fuzz itself is built with the default stable # default: cargo-fuzz itself is built with the default stable
# toolchain below, and only `fuzz build`/`fuzz run` are run with # toolchain below, and only `fuzz build`/`fuzz run` are run with
# the GNU-host nightly below. Building cargo-fuzz's own dependency # `+nightly`. Building cargo-fuzz's own dependency tree under a
# tree under a nightly override can trip build scripts (e.g. rustix) that gate # nightly override can trip build scripts (e.g. rustix) that gate
# unstable internal attributes on detecting a nightly compiler. # unstable internal attributes on detecting a nightly compiler.
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update run: rustup toolchain install nightly --profile minimal --no-self-update
- name: Install cargo-fuzz - name: Install cargo-fuzz
run: | run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}"
set -euo pipefail
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" cargo-fuzz
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Build all fuzz targets - name: Build all fuzz targets
# libFuzzer sanitizers require dynamically linked GNU binaries; the run: cargo +nightly fuzz build
# runner's default Rust host can be static musl.
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
working-directory: fuzz working-directory: fuzz
- name: Run bounded fuzz smoke for every target - name: Run bounded fuzz smoke for every target
@@ -71,9 +57,9 @@ jobs:
# regression; this is not meant to be exhaustive fuzzing. # regression; this is not meant to be exhaustive fuzzing.
run: | run: |
set -euo pipefail set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint 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}" 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 \ -max_total_time=10 \
-timeout=5 -timeout=5
echo "::endgroup::" echo "::endgroup::"
@@ -30,16 +30,15 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
cache: false cache-key: interop-netem
- name: Check host tools and network administration access - name: Check host tools
run: | run: |
ip -V ip -V
tc -V tc -V
go version go version
sudo -n true
- name: Install upstream irtt - name: Install upstream irtt
run: | run: |
@@ -63,14 +62,14 @@ jobs:
run: | run: |
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)" latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
if [ -n "$latest_summary" ]; then if [ -n "$latest_summary" ]; then
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY" cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
else 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 fi
- name: Upload netem interop artifacts - name: Upload netem interop artifacts
if: always() if: always()
uses: https://gitea.com/actions/gitea-upload-artifact@v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with: with:
name: interop-netem name: interop-netem
path: target/interop-netem path: target/interop-netem
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs: jobs:
scan-scheduled: scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with: with:
# Example of specifying custom arguments # Example of specifying custom arguments
scan-args: |- scan-args: |-
@@ -40,7 +40,7 @@ jobs:
./ ./
scan-pr: scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
with: with:
# Example of specifying custom arguments # Example of specifying custom arguments
scan-args: |- scan-args: |-
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with: with:
components: clippy components: clippy
cache-key: clippy-sarif cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+31
View File
@@ -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
+79
View File
@@ -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
-1
View File
@@ -173,7 +173,6 @@ Use the narrowest useful checks first. For substantive workspace changes, final
cargo fmt --all -- --check cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked cargo test --workspace --all-targets --all-features --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
``` ```
-53
View File
@@ -10,12 +10,6 @@ release. The format is loosely based on
## irtt-proto ## irtt-proto
### 0.5.1
#### Added
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
### 0.5.0 ### 0.5.0
#### Added #### Added
@@ -34,24 +28,6 @@ release. The format is loosely based on
## irtt-client ## irtt-client
### 0.5.3
#### Added
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
### 0.5.2
#### Fixed
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
### 0.5.1
#### Added
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
### 0.5.0 ### 0.5.0
#### Added #### Added
@@ -74,12 +50,6 @@ release. The format is loosely based on
## irtt-server ## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2 ### 0.5.2
#### Changed #### Changed
@@ -119,12 +89,6 @@ release. The format is loosely based on
## irtt-stats ## irtt-stats
### 0.5.1
#### Added
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
### 0.5.0 ### 0.5.0
#### Fixed #### Fixed
@@ -138,23 +102,6 @@ release. The format is loosely based on
## irtt-rs ## 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 ### 0.6.2
#### Added #### Added
Generated
+60 -67
View File
@@ -213,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.7" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -223,9 +223,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.7" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -235,14 +235,14 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.7" version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -385,9 +385,9 @@ dependencies = [
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"darling_macro", "darling_macro",
@@ -395,26 +395,26 @@ dependencies = [
[[package]] [[package]]
name = "darling_core" name = "darling_core"
version = "0.24.1" version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
dependencies = [ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.24.1" version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -496,9 +496,9 @@ dependencies = [
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
@@ -710,12 +710,12 @@ dependencies = [
"indoc", "indoc",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "irtt-client" name = "irtt-client"
version = "0.5.3" version = "0.5.0"
dependencies = [ dependencies = [
"irtt-proto", "irtt-proto",
"irtt-server", "irtt-server",
@@ -738,7 +738,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.1" version = "0.5.0"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
@@ -749,7 +749,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.1" version = "0.6.2"
dependencies = [ dependencies = [
"clap", "clap",
"crossterm", "crossterm",
@@ -764,7 +764,7 @@ dependencies = [
[[package]] [[package]]
name = "irtt-server" name = "irtt-server"
version = "0.5.3" version = "0.5.2"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"irtt-proto", "irtt-proto",
@@ -776,10 +776,9 @@ dependencies = [
[[package]] [[package]]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.1" version = "0.5.0"
dependencies = [ dependencies = [
"irtt-client", "irtt-client",
"measurement-stats",
"proptest", "proptest",
] ]
@@ -806,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.105" version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -882,15 +881,15 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.34" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.18.4" version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
dependencies = [ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
@@ -915,12 +914,6 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "measurement-stats"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edbfa45a060f44b15568637075ecebbe258c22f86ce6f2b5d58d7a64cbdb689"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@@ -950,9 +943,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.3" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@@ -1125,9 +1118,9 @@ dependencies = [
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.9.1" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
dependencies = [ dependencies = [
"memchr", "memchr",
"ucd-trie", "ucd-trie",
@@ -1135,9 +1128,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.9.1" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1145,9 +1138,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.9.1" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@@ -1158,9 +1151,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.9.1" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
dependencies = [ dependencies = [
"pest", "pest",
] ]
@@ -1192,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared",
"rand 0.8.8", "rand 0.8.7",
] ]
[[package]] [[package]]
@@ -1301,9 +1294,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.8" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@@ -1576,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -1635,9 +1628,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.0" version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -1712,9 +1705,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.5" version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1847,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -1893,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -1951,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.26.0" version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [ dependencies = [
"atomic", "atomic",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2002,9 +1995,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -2015,9 +2008,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -2025,22 +2018,22 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 2.0.119",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
+1 -1
View File
@@ -18,7 +18,7 @@ homepage = "https://github.com/Lochnair/irtt-rs"
[workspace.dependencies] [workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" } irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.5.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-server = { version = "0.5.2", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" } irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
+1 -1
View File
@@ -10,7 +10,7 @@
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder FROM --platform=$BUILDPLATFORM rust:1.88-alpine AS builder
COPY --from=xx / / COPY --from=xx / /
-1
View File
@@ -171,7 +171,6 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
```sh ```sh
cargo fmt --all -- --check cargo fmt --all -- --check
cargo test --workspace --locked cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
``` ```
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.1" version = "0.6.2"
description = "IRTT-compatible client, TUI, and server applications" description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -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"] tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
[dependencies] [dependencies]
clap = { version = "4.6.7", features = ["derive", "env"] } clap = { version = "4.6.6", features = ["derive", "env"] }
crossterm = { version = "0.29.0", optional = true } crossterm = { version = "0.29.0", optional = true }
ctrlc = { version = "3.5.2", features = ["termination"] } ctrlc = { version = "3.5.2", features = ["termination"] }
irtt-client = { workspace = true, optional = true } irtt-client = { workspace = true, optional = true }
+1 -2
View File
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) { match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client), "irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui), "irtt-tui" => Some(RequestedApplet::Tui),
"irtt-server" => Some(RequestedApplet::Server), "irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None, "irtt-rs" => None,
_ => None, _ => None,
} }
@@ -199,7 +199,6 @@ mod tests {
detect_applet_from_argv0("irtt-server"), detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server) Some(RequestedApplet::Server)
); );
assert_eq!(detect_applet_from_argv0("irttd"), None);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None); assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None); assert_eq!(detect_applet_from_argv0("custom-name"), None);
} }
+7 -33
View File
@@ -11,22 +11,15 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")] #[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs { pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg( #[arg(
value_name = "TARGET", value_name = "TARGET",
num_args = 0.., num_args = 0..,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target." long_help = "Server address/host, optionally prefixed with LABEL=. 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>, pub targets: Vec<TargetArg>,
/// Read complete desired target sets from standard input in continuous mode.
#[arg(
long,
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
)]
pub targets_stdin: bool,
/// Managed group pacing for multi-target mode. /// Managed group pacing for multi-target mode.
#[arg( #[arg(
long, long,
@@ -41,7 +34,7 @@ pub struct ClientArgs {
default_value = "10s", default_value = "10s",
value_parser = parse_test_duration, value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode", help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary 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, pub duration: Duration,
@@ -93,16 +86,12 @@ impl ClientArgs {
/// Targets are only required here. `--list-columns` returns before this is /// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target. /// called, so listing columns still needs no target.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> { pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned());
}
prepare_managed_run( prepare_managed_run(
&self.common, &self.common,
self.duration, self.duration,
TargetSelection { TargetSelection {
targets: &self.targets, targets: &self.targets,
pacing: self.pacing, pacing: self.pacing,
stdin_controlled: self.targets_stdin,
}, },
) )
} }
@@ -170,14 +159,14 @@ mod tests {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1); assert_eq!(args.targets.len(), 1);
assert_eq!(args.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.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION); assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1)); assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table); assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None); assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto); assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.targets_stdin);
assert!(!args.is_continuous()); assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both); assert_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -265,8 +254,8 @@ mod tests {
#[test] #[test]
fn invalid_labelled_target_syntax_is_rejected() { fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err()); assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err()); assert!(parse(&["label="]).is_err());
} }
#[test] #[test]
@@ -320,21 +309,6 @@ mod tests {
assert_eq!(config.hmac_key, Some(b"secret".to_vec())); assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
} }
#[test]
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
let setup = args.prepare().unwrap();
assert!(setup.targets.is_empty());
assert!(setup.stdin_controlled);
let config = setup.managed_config();
assert_eq!(
config.completion,
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
);
assert_eq!(config.max_live_target_generations, 256);
}
#[test] #[test]
fn output_format_summary_policy_is_table_only() { fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary()); assert!(OutputFormat::Table.prints_summary());
+10 -778
View File
@@ -1,16 +1,16 @@
use std::collections::BTreeMap;
use std::{ use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, collections::HashSet,
io::{self, BufRead, Write}, io::{self, Write},
sync::{atomic::AtomicBool, Arc, Mutex}, sync::atomic::AtomicBool,
thread, thread,
time::Duration, time::Duration,
}; };
use irtt_client::{ use irtt_client::{
managed::{ managed::{
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason, BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus, ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
}, },
ClientEvent, ClientEvent,
}; };
@@ -20,13 +20,13 @@ use super::{
output::{EventRenderStats, OutputConfig}, output::{EventRenderStats, OutputConfig},
}; };
use crate::shared::client::expected_probe_count;
use crate::shared::client::{ use crate::shared::client::{
expected_probe_count, is_shutdown_requested, parse_stdin_target_set, is_shutdown_requested,
session::{ session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close, drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary, ManagedDrainState, request_managed_stop_once, should_print_final_summary, ManagedDrainState,
}, },
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
}; };
use irtt_stats::{StatsCollector, StatsConfig}; use irtt_stats::{StatsCollector, StatsConfig};
@@ -41,7 +41,6 @@ const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB; const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20); const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub fn run_stream( pub fn run_stream(
args: ClientArgs, args: ClientArgs,
@@ -62,9 +61,6 @@ pub fn run_stream(
args.verbose, args.verbose,
) )
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown_requested);
}
let continuous = args.is_continuous(); let continuous = args.is_continuous();
let target_count = setup.target_count(); let target_count = setup.target_count();
@@ -200,561 +196,6 @@ pub fn run_stream(
Ok(()) Ok(())
} }
#[derive(Clone)]
struct StdinTargetSet {
revision: u64,
targets: Vec<ManagedTargetConfig>,
}
#[derive(Default)]
struct StdinMailboxState {
next_revision: u64,
latest: Option<StdinTargetSet>,
eof: bool,
fatal: Option<String>,
}
#[derive(Default)]
struct StdinMailbox {
state: Mutex<StdinMailboxState>,
}
impl StdinMailbox {
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next_revision = state.next_revision.saturating_add(1);
state.latest = Some(StdinTargetSet {
revision: state.next_revision,
targets,
});
}
fn finish(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof = true;
}
fn fail(&self, error: String) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.fatal = Some(error);
state.latest = None;
}
fn latest(&self) -> Option<StdinTargetSet> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.clone()
}
fn clear_if_current(&self, revision: u64) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
{
state.latest = None;
}
}
fn is_current(&self, revision: u64) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
}
fn fatal(&self) -> Option<String> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fatal
.clone()
}
fn is_finished(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof
}
}
fn read_stdin_record<R: BufRead>(
reader: &mut R,
buffer: &mut Vec<u8>,
) -> io::Result<Option<String>> {
buffer.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
if buffer.is_empty() {
return Ok(None);
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
reader.consume(1);
buffer.pop();
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
let before_newline = &available[..newline];
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
buffer.extend_from_slice(before_newline);
reader.consume(newline + 1);
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
let take = available.len().min(remaining);
buffer.extend_from_slice(&available[..take]);
reader.consume(take);
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
}
}
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
let mut line = 0_u64;
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
loop {
match read_stdin_record(reader, &mut record) {
Ok(Some(record)) => {
line = line.saturating_add(1);
if record.is_empty() {
continue;
}
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => {
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
}
Err(error) => {
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
return;
}
}
}
Ok(None) => {
mailbox.finish();
return;
}
Err(_) => {
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
return;
}
}
}
}
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
thread::Builder::new()
.name("irtt-targets-stdin".to_owned())
.spawn(move || {
let stdin = io::stdin();
read_stdin_target_sets(&mut stdin.lock(), &mailbox);
})
.map(|_| ())
}
#[derive(Clone)]
struct RetryAfterStatus {
revision: u64,
status: Arc<ManagedStatus>,
}
fn retry_waits_for_status_change(
retry_after: &mut Option<RetryAfterStatus>,
target_set: &StdinTargetSet,
status: &Arc<ManagedStatus>,
) -> bool {
let Some(retry) = retry_after else {
return false;
};
if retry.revision != target_set.revision {
*retry_after = None;
return false;
}
Arc::ptr_eq(&retry.status, status)
}
fn retry_after_capacity_rejection(
mailbox: &StdinMailbox,
target_set: &StdinTargetSet,
status: Arc<ManagedStatus>,
) -> Option<RetryAfterStatus> {
mailbox
.is_current(target_set.revision)
.then_some(RetryAfterStatus {
revision: target_set.revision,
status,
})
}
fn apply_latest_stdin_target_set(
handle: &ManagedClientHandle,
mailbox: &StdinMailbox,
retry_after: &mut Option<RetryAfterStatus>,
) -> Result<(), String> {
let Some(target_set) = mailbox.latest() else {
*retry_after = None;
return Ok(());
};
let status = handle.status();
if retry_waits_for_status_change(retry_after, &target_set, &status) {
return Ok(());
}
let receipt = handle
.update_targets(target_set.targets.clone())
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
match receipt.blocking_wait() {
Ok(_) => {
mailbox.clear_if_current(target_set.revision);
*retry_after = None;
Ok(())
}
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
Ok(())
}
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
}
}
struct BoundedTargetSet {
limit: usize,
order: VecDeque<TargetInstance>,
members: HashSet<TargetInstance>,
}
impl BoundedTargetSet {
fn new(limit: usize) -> Self {
Self {
limit,
order: VecDeque::with_capacity(limit),
members: HashSet::with_capacity(limit),
}
}
fn insert(&mut self, target: TargetInstance) -> bool {
if !self.members.insert(target.clone()) {
return false;
}
if self.order.len() == self.limit {
let evicted = self
.order
.pop_front()
.expect("bounded target order is nonempty");
self.members.remove(&evicted);
}
self.order.push_back(target);
true
}
}
fn process_stdin_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> {
match event {
ManagedEvent::TargetStateChanged { target, .. } => {
stats
.entry(target)
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
ManagedEvent::Client { target, event } => {
let collector = stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
print_events_with_stats(
stream_output,
std::slice::from_ref(&event),
Some(target.id.as_str()),
collector,
)?;
}
ManagedEvent::TargetFinished { outcome }
if terminal_targets.insert(outcome.target.clone()) =>
{
report_target_failure(&outcome);
}
_ => {}
}
Ok(())
}
fn drain_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_stdin_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
) {
let live = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<HashSet<_>>();
stats.retain(|target, _| {
live.contains(target)
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
});
}
fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
) -> BTreeSet<TargetInstance> {
let targets = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<BTreeSet<_>>();
for target in &targets {
stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
targets
}
#[derive(Clone)]
enum StdinStop {
Interrupted,
Eof,
Fatal(String),
}
fn stdin_stop_request(
shutdown_requested: &AtomicBool,
mailbox: &StdinMailbox,
) -> Option<StdinStop> {
if is_shutdown_requested(shutdown_requested) {
Some(StdinStop::Interrupted)
} else if let Some(error) = mailbox.fatal() {
Some(StdinStop::Fatal(error))
} else if mailbox.is_finished() {
Some(StdinStop::Eof)
} else {
None
}
}
fn run_stdin_stream(
setup: ManagedRunSetup,
output_config: OutputConfig,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
)?;
let handle = owner.handle();
let mailbox = Arc::new(StdinMailbox::default());
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut retry_after = None;
let mut dropped_events = 0_u64;
let mut stop_requested = false;
let mut stop = None;
let mut summary_targets = None;
let mut subscription_closed = false;
loop {
let status = handle.status();
if stop.is_none() {
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(requested_stop);
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
} else if let Err(error) =
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
{
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(StdinStop::Fatal(error));
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
}
let drain_state = drain_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
}
let outcome = owner.join()?;
drain_final_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
report_target_failure(target);
}
}
if outcome.discarded_target_outcomes != 0 {
eprintln!(
"irtt-rs: warning: {} final target outcomes were discarded",
outcome.discarded_target_outcomes
);
}
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
stream_output.print_final_summary = print_summary;
stream_output.show_running_only_summary_note = print_summary;
if let Some(summary_targets) = summary_targets {
let multi_target = summary_targets.len() > 1;
for target in summary_targets {
let stats = stats
.get(&target)
.expect("summary snapshot retains every selected target collector");
if multi_target && stream_output.config.prints_summary() {
writeln!(stream_output.out)?;
writeln!(
stream_output.out,
"target: {} (generation {})",
target.id, target.generation
)?;
}
stream_output.print_summary(stats)?;
}
}
stream_output.out.flush()?;
match stop {
Some(StdinStop::Fatal(error)) => Err(error.into()),
_ => match outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Err(format!("managed driver failed: {failure}").into())
}
_ => Ok(()),
},
}
}
fn process_event<W: Write>( fn process_event<W: Write>(
event: ManagedEvent, event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>, stream_output: &mut StreamOutput<'_, W>,
@@ -943,218 +384,9 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
mod tests { mod tests {
use super::*; use super::*;
use irtt_client::managed::{ use irtt_client::managed::{
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
}; };
use std::{io::BufReader, io::Cursor, sync::Arc}; use std::sync::Arc;
#[test]
fn stdin_record_reader_removes_only_line_framing() {
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(" first ".to_owned())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(String::new())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some("third\r".to_owned())
);
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
}
#[test]
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("maximum size"));
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
}
#[test]
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let submitted = mailbox.latest().unwrap();
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let pending = mailbox.latest().unwrap();
mailbox.clear_if_current(submitted.revision);
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
}
#[test]
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(Vec::new());
let target_set = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let capacity_freed = Arc::new(status_with_targets(&[]));
// Capacity becomes available after the transaction rejected but before
// its acknowledgement reaches the controller. The rejection path must
// retain the pre-submission status, not this later snapshot.
let mut retry_after =
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
assert!(retry_waits_for_status_change(
&mut retry_after,
&target_set,
&saturated
));
assert!(!retry_waits_for_status_change(
&mut retry_after,
&target_set,
&capacity_freed
));
}
#[test]
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let applied = mailbox.latest().unwrap();
mailbox.clear_if_current(applied.revision);
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let rejected = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
let replacement = mailbox.latest().unwrap();
assert!(!retry_waits_for_status_change(
&mut retry_after,
&replacement,
&Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]))
));
assert!(retry_after.is_none());
assert_eq!(replacement.targets[0].id.as_str(), "c");
mailbox.clear_if_current(replacement.revision);
assert!(mailbox.latest().is_none());
}
#[test]
fn eof_and_shutdown_stop_without_waiting_for_the_stdin_reader() {
let mailbox = StdinMailbox::default();
let shutdown_requested = AtomicBool::new(false);
assert!(stdin_stop_request(&shutdown_requested, &mailbox).is_none());
mailbox.finish();
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Eof)
));
let mailbox = StdinMailbox::default();
shutdown_requested.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Interrupted)
));
}
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
ManagedStatus {
lifecycle: ManagedLifecycle::Running,
stop_requested: false,
applied_command_sequence: 0,
desired_target_count: targets.len(),
connecting_target_count: 0,
opening_target_count: 0,
active_target_count: 0,
draining_target_count: 0,
closing_target_count: 0,
terminal_target_count: 0,
total_target_outcomes: 0,
successful_target_outcomes: 0,
failed_target_outcomes: 0,
peer_closed_target_outcomes: 0,
discarded_target_outcomes: 0,
targets: Arc::from(
targets
.iter()
.cloned()
.map(|target| ManagedTargetStatus {
target,
desired: true,
lifecycle: ManagedTargetLifecycle::Active,
server_addr: Arc::from("127.0.0.1:2112"),
remote: None,
})
.collect::<Vec<_>>()
.into_boxed_slice(),
),
recent_target_outcomes: Arc::from([]),
final_outcome: None,
}
}
#[test]
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
let retired = TargetInstance {
id: TargetId::from("edge"),
generation: 1,
};
let replacement = TargetInstance {
id: TargetId::from("edge"),
generation: 2,
};
let live_status = status_with_targets(std::slice::from_ref(&replacement));
let empty_status = status_with_targets(&[]);
let mut stats = BTreeMap::from([
(retired.clone(), StatsCollector::new(stats_config(true))),
(replacement.clone(), StatsCollector::new(stats_config(true))),
]);
reconcile_stdin_stats(&mut stats, &live_status, None);
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
stats.insert(retired, StatsCollector::new(stats_config(true)));
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
assert_eq!(
stats.keys().cloned().collect::<Vec<_>>(),
[TargetInstance {
id: TargetId::from("edge"),
generation: 2,
}]
);
}
#[test]
fn terminal_deduplication_is_bounded_under_target_churn() {
let mut terminal_targets = BoundedTargetSet::new(2);
let target = |generation| TargetInstance {
id: TargetId::from("edge"),
generation,
};
assert!(terminal_targets.insert(target(1)));
assert!(terminal_targets.insert(target(2)));
assert!(terminal_targets.insert(target(3)));
assert!(terminal_targets.insert(target(1)));
assert!(!terminal_targets.insert(target(3)));
}
fn long_finite_args() -> ClientArgs { fn long_finite_args() -> ClientArgs {
use clap::Parser; use clap::Parser;
+1 -50
View File
@@ -106,22 +106,10 @@ pub struct ServerArgs {
pub timestamp_allowance: Option<TimestampAllowanceArg>, pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking. /// Refuse to provide requested DSCP marking.
//
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
// all. A flag's default parser accepts only the literals `true` and
// `false`, so the ordinary container/orchestrator spelling
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
// parsing and the server refused to start. Falsey semantics are clap's own
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
// `n`, `off` and empty are off, anything else is on, and an absent
// variable is off as before. The command-line flag is unaffected: it still
// takes no value and still means "on".
#[arg( #[arg(
long, long,
env = "IRTT_SERVER_NO_DSCP", 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."
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
)] )]
pub no_dscp: bool, pub no_dscp: bool,
} }
@@ -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] #[test]
fn an_explicit_flag_overrides_the_environment() { fn an_explicit_flag_overrides_the_environment() {
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || { with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
+6 -6
View File
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] #[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs { pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg( #[arg(
value_name = "TARGET", value_name = "TARGET",
num_args = 1.., num_args = 1..,
required = true, required = true,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= 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>, pub targets: Vec<TargetArg>,
@@ -58,7 +58,6 @@ impl TuiArgs {
TargetSelection { TargetSelection {
targets: &self.targets, targets: &self.targets,
pacing: self.pacing, pacing: self.pacing,
stdin_controlled: false,
}, },
) )
} }
@@ -108,7 +107,8 @@ mod tests {
fn tui_parser_defaults_to_continuous_and_has_no_output_option() { fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1); assert_eq!(args.targets.len(), 1);
assert_eq!(args.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.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION); assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous()); assert!(args.is_continuous());
@@ -181,8 +181,8 @@ mod tests {
#[test] #[test]
fn invalid_labelled_target_syntax_is_rejected() { fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err()); assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err()); assert!(parse(&["label="]).is_err());
} }
#[test] #[test]
+3 -6
View File
@@ -9,12 +9,9 @@ pub use args::{
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg, ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
}; };
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT}; pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use prepare::{ pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
};
pub use session::is_shutdown_requested; pub use session::is_shutdown_requested;
pub use targets::{ pub use targets::{
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg, parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
PreparedTarget, TargetArg, TargetSpec, TargetSpec,
}; };
+11 -36
View File
@@ -22,9 +22,6 @@ use super::{
/// Capacity of the lossy managed presentation event channel. /// Capacity of the lossy managed presentation event channel.
pub const MANAGED_EVENT_CAPACITY: usize = 16_384; pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Target and pacing arguments common to the client applets. /// Target and pacing arguments common to the client applets.
/// ///
@@ -34,7 +31,6 @@ pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
pub struct TargetSelection<'a> { pub struct TargetSelection<'a> {
pub targets: &'a [TargetArg], pub targets: &'a [TargetArg],
pub pacing: GroupPacingArg, pub pacing: GroupPacingArg,
pub stdin_controlled: bool,
} }
/// Everything a managed run needs, prepared and validated together. /// Everything a managed run needs, prepared and validated together.
@@ -50,8 +46,6 @@ pub struct ManagedRunSetup {
pub client: ClientConfig, pub client: ClientConfig,
/// Send coordination across active targets. /// Send coordination across active targets.
pub pacing: ManagedPacing, pub pacing: ManagedPacing,
/// Whether this stream may replace its desired set from stdin.
pub stdin_controlled: bool,
} }
impl ManagedRunSetup { impl ManagedRunSetup {
@@ -76,7 +70,7 @@ impl ManagedRunSetup {
/// Managed driver configuration for this run. /// Managed driver configuration for this run.
pub fn managed_config(&self) -> ManagedClientConfig { pub fn managed_config(&self) -> ManagedClientConfig {
let target_count = self.target_count(); let target_count = self.target_count();
let mut config = ManagedClientConfig { ManagedClientConfig {
client: self.client.clone(), client: self.client.clone(),
pacing: self.pacing, pacing: self.pacing,
completion: ManagedCompletionPolicy::FinishWhenQuiescent, completion: ManagedCompletionPolicy::FinishWhenQuiescent,
@@ -84,13 +78,7 @@ impl ManagedRunSetup {
outcome_history_limit: target_count, outcome_history_limit: target_count,
max_live_target_generations: target_count, max_live_target_generations: target_count,
..ManagedClientConfig::default() ..ManagedClientConfig::default()
};
if self.stdin_controlled {
config.completion = ManagedCompletionPolicy::ExplicitStop;
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
} }
config
} }
} }
@@ -103,22 +91,12 @@ pub fn prepare_managed_run(
duration: Duration, duration: Duration,
selection: TargetSelection<'_>, selection: TargetSelection<'_>,
) -> Result<ManagedRunSetup, String> { ) -> Result<ManagedRunSetup, String> {
let specs = if selection.stdin_controlled { let specs = target_specs(selection.targets)?;
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
return Err(format!(
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
));
}
super::targets::target_specs_with_empty(selection.targets, true)?
} else {
target_specs(selection.targets)?
};
let targets = prepare_managed_targets(specs)?; let targets = prepare_managed_targets(specs)?;
Ok(ManagedRunSetup { Ok(ManagedRunSetup {
targets, targets,
client: common.to_client_config(duration), client: common.to_client_config(duration),
pacing: selection.pacing.into(), pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled,
}) })
} }
@@ -141,11 +119,17 @@ mod tests {
} }
fn unlabeled(addr: &str) -> TargetArg { fn unlabeled(addr: &str) -> TargetArg {
TargetArg::new(addr) TargetArg {
label: None,
addr: addr.to_owned(),
}
} }
fn labeled(label: &str, addr: &str) -> TargetArg { fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg::new(format!("{label}={addr}")) TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
} }
fn prepare( fn prepare(
@@ -153,15 +137,7 @@ mod tests {
pacing: GroupPacingArg, pacing: GroupPacingArg,
duration: Duration, duration: Duration,
) -> Result<ManagedRunSetup, String> { ) -> Result<ManagedRunSetup, String> {
prepare_managed_run( prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
&common(&[]),
duration,
TargetSelection {
targets,
pacing,
stdin_controlled: false,
},
)
} }
#[test] #[test]
@@ -236,7 +212,6 @@ mod tests {
TargetSelection { TargetSelection {
targets: &targets, targets: &targets,
pacing: GroupPacingArg::Staggered, pacing: GroupPacingArg::Staggered,
stdin_controlled: false,
}, },
) )
.unwrap(); .unwrap();
+130 -395
View File
@@ -1,99 +1,26 @@
use std::{collections::HashSet, fmt}; use std::collections::HashSet;
use clap::ValueEnum; use clap::ValueEnum;
use irtt_client::{ use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
ClientAuthConfig,
};
/// One raw positional target captured by Clap. #[derive(Debug, Clone, PartialEq, Eq)]
///
/// Parsing happens during run preparation so invalid target syntax never makes
/// Clap include the original argument (which may carry an HMAC key) in an
/// error diagnostic.
#[derive(Clone, PartialEq, Eq)]
pub struct TargetArg { pub struct TargetArg {
input: String, pub label: Option<String>,
pub addr: String,
} }
impl TargetArg { #[derive(Debug, Clone, PartialEq, Eq)]
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
}
}
}
impl fmt::Debug for TargetArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TargetArg(<redacted>)")
}
}
/// Capture one positional target without exposing it through Clap diagnostics.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
Ok(TargetArg::new(input))
}
#[derive(Clone, PartialEq, Eq)]
pub enum TargetAuth {
Inherit,
Override(Vec<u8>),
Disable,
}
impl fmt::Debug for TargetAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Inherit => f.write_str("Inherit"),
Self::Override(_) => f.write_str("Override(<redacted>)"),
Self::Disable => f.write_str("Disable"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TargetSpec { pub struct TargetSpec {
pub label: String, pub label: String,
pub addr: String, pub addr: String,
pub auth: TargetAuth,
} }
impl fmt::Debug for TargetSpec { #[derive(Debug, Clone)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetSpec")
.field("label", &self.label)
.field("addr", &self.addr)
.field("auth", &self.auth)
.finish()
}
}
#[derive(Clone)]
pub struct PreparedTarget { pub struct PreparedTarget {
pub label: String, pub label: String,
pub managed: ManagedTargetConfig, pub managed: ManagedTargetConfig,
} }
impl fmt::Debug for PreparedTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field(
"auth",
&match &self.managed.auth {
None => TargetAuth::Inherit,
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
TargetAuth::Override(Vec::new())
}
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
},
)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg { pub enum GroupPacingArg {
Staggered, Staggered,
@@ -109,383 +36,191 @@ impl From<GroupPacingArg> for ManagedPacing {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
enum TargetParseError { pub fn parse_target(input: &str) -> Result<TargetArg, String> {
EmptyLabel, match input.split_once('=') {
EmptyAddress, None => Ok(TargetArg {
InvalidEscape, label: None,
EmptyTarget, addr: input.to_owned(),
} }),
Some((label, addr)) => {
impl fmt::Display for TargetParseError { if label.is_empty() {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { return Err("target label must not be empty".to_owned());
let message = match self { }
Self::EmptyLabel => "target label must not be empty", if addr.is_empty() {
Self::EmptyAddress => "target address must not be empty", return Err("target address must not be empty".to_owned());
Self::InvalidEscape => "invalid target escape sequence", }
Self::EmptyTarget => "target must not be empty", Ok(TargetArg {
}; label: Some(label.to_owned()),
f.write_str(message) addr: addr.to_owned(),
})
}
} }
} }
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> { pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
let mut parts = Vec::new();
let mut start = 0;
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
parts.push(&input[start..index]);
start = index + ch.len_utf8();
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
parts.push(&input[start..]);
Ok(parts)
}
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn unescape(input: &str) -> Result<String, TargetParseError> {
let mut output = String::with_capacity(input.len());
let mut escaped = false;
for ch in input.chars() {
if escaped {
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
return Err(TargetParseError::InvalidEscape);
}
output.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
output.push(ch);
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(output)
}
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if input[index..].starts_with("@hmac=") {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn parse_target_syntax(
input: &str,
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
if input.is_empty() {
return Err(TargetParseError::EmptyTarget);
}
let (main, auth) = match hmac_modifier(input)? {
Some(index) => {
let value = unescape(&input[index + "@hmac=".len()..])?;
let auth = if value.is_empty() {
TargetAuth::Disable
} else {
TargetAuth::Override(value.into_bytes())
};
(&input[..index], auth)
}
None => (input, TargetAuth::Inherit),
};
let (label, address) = match first_unescaped(main, '=')? {
None => (None, unescape(main)?),
Some(index) => {
let label = unescape(&main[..index])?;
let address = unescape(&main[index + '='.len_utf8()..])?;
(Some(label), address)
}
};
if label.as_deref().is_some_and(str::is_empty) {
return Err(TargetParseError::EmptyLabel);
}
if address.is_empty() {
return Err(TargetParseError::EmptyAddress);
}
Ok((label, address, auth))
}
pub(crate) fn target_specs_with_empty(
targets: &[TargetArg],
allow_empty: bool,
) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::with_capacity(targets.len()); let mut specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new(); let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
for (index, target) in targets.iter().enumerate() { for target in targets {
let (explicit_label, addr, auth) = parse_target_syntax(&target.input) let label = match &target.label {
.map_err(|error| format!("invalid target {}: {error}", index + 1))?; Some(label) => label.clone(),
let label = match explicit_label {
Some(label) => label,
None => { None => {
let count = unlabeled_counts.entry(addr.clone()).or_default(); let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
*count += 1; *count += 1;
if *count == 1 { if *count == 1 {
addr.clone() target.addr.clone()
} else { } 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()); return Err("at least one target is required unless --list-columns is set".to_owned());
} }
let mut labels = HashSet::new(); let mut labels = HashSet::new();
for spec in &specs { for spec in &specs {
if !labels.insert(spec.label.clone()) { if !labels.insert(spec.label.clone()) {
return Err("duplicate target label".to_owned()); return Err(format!("duplicate target label {:?}", spec.label));
} }
} }
Ok(specs) Ok(specs)
} }
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> { pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len()); let mut targets = Vec::with_capacity(specs.len());
for spec in specs { for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = match spec.auth {
TargetAuth::Inherit => None,
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
hmac_key: Some(hmac_key),
}),
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
};
targets.push(PreparedTarget { targets.push(PreparedTarget {
label: spec.label, label: spec.label.clone(),
managed, managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
}); });
} }
Ok(targets) Ok(targets)
} }
/// Parse one complete stdin target set after its line terminator was removed.
///
/// Commas frame stdin elements only; positional target arguments do not use
/// this framing and may therefore contain raw commas.
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
if record == "[]" {
return Ok(Vec::new());
}
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
if elements.len() > maximum_targets {
return Err(format!(
"target set exceeds the {maximum_targets}-target limit"
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
prepare_managed_targets(target_specs_with_empty(&args, true)?)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn target(input: &str) -> TargetArg { fn unlabeled(addr: &str) -> TargetArg {
TargetArg::new(input) TargetArg {
label: None,
addr: addr.to_owned(),
}
} }
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> { fn labeled(label: &str, addr: &str) -> TargetArg {
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>()) TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
} }
#[test] #[test]
fn legacy_target_syntax_and_ipv6_remain_supported() { fn parse_target_without_equals_is_unlabeled() {
let parsed = specs(&[ let target = parse_target("host.example").unwrap();
"host.example", assert_eq!(target.label, None);
"host.example:2112", assert_eq!(target.addr, "host.example");
"[::1]:2112",
"eu=host.example", let target = parse_target("host.example:2112").unwrap();
]) assert_eq!(target.label, None);
.unwrap(); assert_eq!(target.addr, "host.example:2112");
assert_eq!(parsed[0].label, "host.example");
assert_eq!(parsed[1].label, "host.example:2112"); let target = parse_target("[::1]:2112").unwrap();
assert_eq!(parsed[2].label, "[::1]:2112"); assert_eq!(target.label, None);
assert_eq!(parsed[3].label, "eu"); assert_eq!(target.addr, "[::1]:2112");
assert!(parsed
.iter()
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
} }
#[test] #[test]
fn target_hmac_supports_inherit_override_disable_and_base64_padding() { fn parse_target_with_equals_splits_on_first() {
let parsed = specs(&[ let target = parse_target("eu=host.example").unwrap();
"default=default.example", assert_eq!(target.label, Some("eu".to_owned()));
"private=private.example@hmac=YWJjZA==", assert_eq!(target.addr, "host.example");
"public=public.example@hmac=",
]) let target = parse_target("eu=host.example:2112").unwrap();
.unwrap(); assert_eq!(target.label, Some("eu".to_owned()));
assert!(matches!(parsed[0].auth, TargetAuth::Inherit)); assert_eq!(target.addr, "host.example:2112");
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
} }
#[test] #[test]
fn target_delimiters_are_escaped_contextually() { fn parse_target_rejects_empty_label_or_address() {
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap(); assert!(parse_target("=host.example").is_err());
assert_eq!(parsed[0].label, "eu=west"); assert!(parse_target("eu=").is_err());
assert_eq!(parsed[0].addr, "host@hmac=literal");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
}
#[test]
fn positional_commas_are_not_reserved() {
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
assert_eq!(parsed[0].addr, "host,region");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
}
#[test]
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
let secret = "very-secret-value";
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
assert!(!error.contains(secret));
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
assert!(
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
);
} }
#[test] #[test]
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() { fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap(); let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
assert_eq!(parsed[0].label, "host-a:2112"); let specs = target_specs(&targets).unwrap();
assert_eq!(parsed[1].label, "host-a:2112#2");
assert_eq!( assert_eq!(specs[0].label, "host-a:2112");
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(), assert_eq!(specs[1].label, "host-a:2112#2");
"duplicate target label"
); let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
} }
#[test] #[test]
fn target_specs_preserve_argument_order_and_unlabeled_counts() { fn target_specs_preserve_argument_order_with_mixed_labels() {
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap(); let targets = vec![
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect(); unlabeled("local"),
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]); labeled("eu", "eu.example"),
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect(); unlabeled("backup"),
assert_eq!( labeled("us", "us.example"),
addresses, ];
["local", "eu.example", "foo", "us.example", "foo"] 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] #[test]
fn target_specs_reject_generated_suffix_collisions() { fn target_specs_reject_generated_suffix_collisions() {
assert_eq!( let targets = vec![
specs(&[ unlabeled("host.example"),
"host.example", unlabeled("host.example"),
"host.example", labeled("host.example#2", "other.example"),
"host.example#2=other.example" ];
]) let err = target_specs(&targets).unwrap_err();
.unwrap_err(), assert!(err.contains("duplicate target label"));
"duplicate target label"
);
} }
#[test] #[test]
fn malformed_target_syntax_is_rejected_without_echoing_input() { fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] { let specs = vec![
assert!(specs(&[input]).is_err(), "{input}"); TargetSpec {
} label: "a".to_owned(),
} addr: "example.test".to_owned(),
},
#[test] TargetSpec {
fn target_preparation_maps_auth_to_managed_config() { label: "b".to_owned(),
let prepared = addr: "example.test".to_owned(),
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap(); },
assert_eq!( ];
prepared[0] let targets = prepare_managed_targets(specs).unwrap();
.managed assert_eq!(targets[0].managed.server_addr, "example.test");
.auth assert_eq!(targets[1].managed.server_addr, "example.test");
.as_ref()
.unwrap()
.hmac_key
.as_deref(),
Some(b"key".as_slice())
);
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
}
#[test]
fn target_preparation_preserves_duplicate_endpoints() {
let prepared =
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
assert_eq!(prepared[0].managed.server_addr, "example.test");
assert_eq!(prepared[1].managed.server_addr, "example.test");
}
#[test]
fn stdin_target_sets_frame_commas_without_trimming_payload() {
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
assert_eq!(
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
Some(b"x,y".as_slice())
);
assert_eq!(parsed[1].label, "b");
assert_eq!(
parse_stdin_target_set(" []", 128).unwrap()[0]
.managed
.server_addr,
" []"
);
assert_eq!(
parse_stdin_target_set("[] ", 128).unwrap()[0]
.managed
.server_addr,
"[] "
);
assert!(parse_stdin_target_set("", 128).is_err());
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
}
#[test]
fn stdin_empty_set_is_exactly_bracket_pair() {
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
} }
} }
+22 -22
View File
@@ -26,30 +26,30 @@ configuration (`try_enable_tx_timestamping` restores it explicitly) and
timing falls back to userspace `sent_at` as it always has. TX capability timing falls back to userspace `sent_at` as it always has. TX capability
failure is never a public error and is never logged from the library. failure is never a public error and is never logged from the library.
### 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 The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
correlation ID — no separate correlation map. On the normal successful-send sequence number — no separate correlation map. This is safe because:
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 effect is confined to the optional timing enhancement. A kernel timestamp - **A failed/nonblocking send never consumes an ID.** Linux increments the
whose ID cannot be matched to a pending or retained timed-out probe is per-socket ID counter (`sk->sk_tskey`) speculatively while building a
discarded, as is any implausible timestamp; `sent_at` remains the ordinary datagram and rolls the increment back (`atomic_dec`) on every error path
userspace fallback. **After every failed probe submission, disable kernel-ID out of `__ip_append_data`/`__ip6_append_data`
correlation for the rest of that session.** A gap means an otherwise plausible (`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
later ID can name the wrong probe, so subsequent error-queue timestamps must `hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
be discarded rather than attached by ID. This is safe because: gap in, or otherwise advance, the ID the kernel will assign to the next
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
- **Normal successful sends track together.** `wire_seq` starts at 0 on every performs the same increment for the non-corked path.) Client-side, the
successful Open (`SessionMachine::commit_open` builds a fresh existing send transaction only calls `commit_probe_sent` — which is the
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via only thing that advances `next_wire_seq` — after a successful socket send;
`commit_probe_sent`, after a confirmed successful socket send. TX a `WouldBlock` result never reaches it.
timestamping is enabled after that same successful Open and before any probe - **The two counters start together and advance together.** `wire_seq`
is sent, so the normal zero points line up. 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 - **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close` only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
+2 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.5.3" version = "0.5.0"
description = "IRTT-compatible Rust client library" description = "IRTT-compatible Rust client library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -44,8 +44,6 @@ required-features = ["tokio"]
doc-scrape-examples = true doc-scrape-examples = true
[dev-dependencies] [dev-dependencies]
# Keep the packaged client testable against the latest published server while irtt-server.workspace = true
# workspace tests continue to exercise the local source through this path.
irtt-server = { version = "0.5.1", path = "../irtt-server" }
proptest = "1.11.0" proptest = "1.11.0"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] } tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
+2 -33
View File
@@ -242,31 +242,6 @@ impl AsyncClientTestHooks {
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async /// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
/// methods are polled by the caller and require a current Tokio runtime with I/O /// methods are polled by the caller and require a current Tokio runtime with I/O
/// and time enabled. /// and time enabled.
///
/// # Example
///
/// Use this low-level adapter when the application already owns Tokio and wants
/// to drive the session lifecycle itself. This example type-checks without
/// contacting a server.
///
/// ```no_run
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
/// let outcome = client.open().await?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe().await?;
/// println!("sent: {sent:?}");
/// let received = client.recv().await?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close().await?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct AsyncClient { pub struct AsyncClient {
socket: tokio::net::UdpSocket, socket: tokio::net::UdpSocket,
@@ -633,14 +608,8 @@ impl AsyncClient {
// No sent_at measurement, no machine commit, no schedule // No sent_at measurement, no machine commit, no schedule
// commit: the transaction is fully discarded and retried // commit: the transaction is fully discarded and retried
// with a fresh send_anchor on the next loop iteration. // with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => { Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
self.machine.invalidate_kernel_tx_correlation(); Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
continue;
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(ClientError::Socket(error)));
}
}; };
#[cfg(not(test))] #[cfg(not(test))]
+6 -39
View File
@@ -103,31 +103,6 @@ use crate::{
/// session, send probes, receive replies, poll timeouts, and close. Callers /// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use the unified managed API in /// that do not need to own this loop can use the unified managed API in
/// [`crate::managed`] when the `tokio` feature is enabled. /// [`crate::managed`] when the `tokio` feature is enabled.
///
/// # Example
///
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
/// polling between sends; see the standalone example for a complete loop.
/// This example type-checks without contacting a server:
///
/// ```no_run
/// use irtt_client::{Client, ClientConfig};
///
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = Client::connect(ClientConfig::default())?;
/// let outcome = client.open()?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe()?;
/// println!("sent: {sent:?}");
/// let received = client.recv_once()?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close()?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
runtime: SessionMachine, runtime: SessionMachine,
@@ -188,13 +163,12 @@ impl Client {
/// ///
/// On success, returns the negotiated open outcome and transitions the /// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state. /// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or /// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated,
/// unrelated datagrams are ignored until the current attempt's absolute /// or unauthenticated datagrams are ignored until the current attempt's
/// deadline, so one attempt may consume several datagrams without /// absolute deadline, so one attempt may consume several datagrams without
/// retransmitting. Silence or ignored traffic eventually produces /// retransmitting. Silence or ignored traffic eventually produces
/// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or /// [`ClientError::OpenTimeout`], while authenticated incompatibility remains
/// incompatible Open reply from the connected peer is terminal; when HMAC /// terminal.
/// is configured, that recognition additionally requires authentication.
/// ///
/// When a trusted reply allocates a token but later negotiation or socket /// When a trusted reply allocates a token but later negotiation or socket
/// preparation fails, the client sends a best-effort cleanup close and /// preparation fails, the client sends a best-effort cleanup close and
@@ -549,18 +523,11 @@ impl Client {
.unwrap_or_else(Instant::now); .unwrap_or_else(Instant::now);
#[cfg(test)] #[cfg(test)]
if fail_send { if fail_send {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(io::Error::other( return Err(ClientError::Socket(io::Error::other(
"injected probe send failure", "injected probe send failure",
))); )));
} }
let bytes = match socket.send(&prepared.bytes) { let bytes = socket.send(&prepared.bytes)?;
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
#[cfg(not(test))] #[cfg(not(test))]
let send_finished_at = Instant::now(); let send_finished_at = Instant::now();
#[cfg(test)] #[cfg(test)]
-57
View File
@@ -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 /// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session /// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams). /// through [`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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig { pub struct ClientConfig {
/// Remote server name or address. /// 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 /// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the /// socket. They do not change the IRTT protocol parameters negotiated with the
/// server. /// server.
///
/// # Example
///
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{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)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig { pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket. /// Local address to bind before connecting the UDP socket.
@@ -231,15 +183,6 @@ pub struct SocketConfig {
/// `None` binds to an unspecified address with an ephemeral port matching /// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family. /// the selected remote address family.
pub bind_addr: Option<SocketAddr>, pub bind_addr: Option<SocketAddr>,
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub bind_to_device: Option<String>,
/// Firewall mark applied to outgoing packets (`SO_MARK`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub mark: Option<u32>,
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
#[cfg(target_os = "freebsd")]
pub fib: Option<u32>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets. /// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
/// ///
/// `None` leaves the platform default unchanged. Values must fit in the /// `None` leaves the platform default unchanged. Values must fit in the
-13
View File
@@ -260,19 +260,6 @@ pub struct RttSample {
/// processing time or comparing wall-clock timestamps from different hosts. It /// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time, /// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`]. /// which is non-negative and represented by [`Duration`].
///
/// # Example
///
/// Signed timing values retain a negative result instead of clamping clock
/// skew or server processing adjustments to zero:
///
/// ```
/// use irtt_client::SignedDuration;
///
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
/// assert!(adjusted_rtt.is_negative());
/// assert_eq!(adjusted_rtt.as_micros(), -250);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration { pub struct SignedDuration {
ns: i128, ns: i128,
@@ -250,11 +250,15 @@ fn dynamic_update_reaches_background_worker() {
.unwrap(); .unwrap();
let handle = owner.handle(); let handle = owner.handle();
let acknowledgement = handle let acknowledgement = test_runtime().block_on(async {
.update_targets(vec![target(&server)]) timeout(
Duration::from_secs(2),
handle.update_targets(vec![target(&server)]).unwrap(),
)
.await
.unwrap() .unwrap()
.blocking_wait() .unwrap()
.unwrap(); });
assert_eq!(acknowledgement.sequence, 1); assert_eq!(acknowledgement.sequence, 1);
server.wait_probe(); server.wait_probe();
test_runtime().block_on(async { test_runtime().block_on(async {
+1 -74
View File
@@ -44,42 +44,6 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1; const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
/// Entry point for constructing a unified Tokio managed task. /// 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)] #[derive(Debug, Default)]
pub struct ManagedClient; pub struct ManagedClient;
@@ -337,19 +301,6 @@ pub struct ManagedCommandReceipt {
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>, receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
} }
impl ManagedCommandReceipt {
/// Block until the target-set transaction is applied or rejected.
///
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
/// must not be called from an asynchronous runtime; use the [`Future`]
/// implementation there instead.
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
self.receiver
.blocking_recv()
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
}
}
impl Future for ManagedCommandReceipt { impl Future for ManagedCommandReceipt {
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>; type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
@@ -1649,30 +1600,6 @@ impl ManagedClientTask {
self.send_gate = self.send_gate.filter(|gate| *gate > now); self.send_gate = self.send_gate.filter(|gate| *gate > now);
} }
/// The next stagger gate after a send was accepted at `accepted_at`.
///
/// The gate advances on its **own** cadence rather than being re-anchored
/// on every observed accept time. Anchoring on `accepted_at` folds each
/// send's wakeup latency into the next slot and then into every slot after
/// it, so the gate — and with it the whole group's send cadence — drifts
/// later without bound while the per-target schedules stay on their
/// absolute grid. The drift is not hypothetical: it shows up as a
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
/// interval, as probe slots the schedule then skips.
///
/// `accepted_at` is still the anchor in the two cases where continuing the
/// old cadence would be wrong: there is no previous gate, or the pacer has
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
/// stranded in the past from releasing a burst of catch-up sends once the
/// group starts moving again.
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
let base = match self.send_gate {
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
_ => accepted_at,
};
base.checked_add(spacing)
}
fn record_stagger_acceptance( fn record_stagger_acceptance(
&mut self, &mut self,
result: SendResult, result: SendResult,
@@ -1686,7 +1613,7 @@ impl ManagedClientTask {
return; return;
}; };
self.last_stagger_send = Some(accepted_at); 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)] #[cfg(test)]
if let Some(observations) = &self.stagger_observations { if let Some(observations) = &self.stagger_observations {
observations.lock().unwrap().push((_active, spacing)); observations.lock().unwrap().push((_active, spacing));
-103
View File
@@ -1576,37 +1576,6 @@ fn authenticated_peer_close_outcome() {
assert!(!has_close(&records)); assert!(!has_close(&records));
} }
#[test]
fn peer_close_leaves_other_explicitly_managed_targets_running() {
let closing = start_server(ServerBehavior::PeerClose, None);
let continuing = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (task, handle) = ManagedClient::task(
managed,
vec![
target("closing", closing.addr),
target("continuing", continuing.addr),
],
)
.unwrap();
let runtime = runtime();
let mut task = Box::pin(task);
drive_task_until(&runtime, &mut task, |task| {
handle.status().peer_closed_target_outcomes == 1
&& task.targets[1].counters.packets_sent != 0
});
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
let stop = handle.stop();
runtime.block_on(task);
runtime.block_on(stop);
closing.finish();
assert!(!probes(&continuing.finish()).is_empty());
}
#[test] #[test]
fn peer_close_during_stop_drain_remains_authoritative() { fn peer_close_during_stop_drain_remains_authoritative() {
let key = b"managed-drain-peer-close".to_vec(); let key = b"managed-drain-peer-close".to_vec();
@@ -1895,78 +1864,6 @@ fn stagger_gate_tracks_active_membership() {
second.finish(); second.finish();
} }
/// The stagger gate must not fold each send's observed wakeup latency into
/// every later slot.
///
/// The gate is the binding constraint on when the *group* may send next (see
/// `next_deadline`), while each target's own schedule stays on an absolute
/// grid anchored at its open. Re-anchoring the gate on every observed accept
/// time therefore made the whole group's cadence drift later without bound:
/// `EchoSent::timer_error` grew monotonically, and once the drift exceeded one
/// interval `skip_missed_probe_slots_at` started dropping probe slots
/// outright. Measured before this was fixed, a single-target 20 s run at a
/// 50 ms interval sent 381 probes instead of 400.
///
/// This asserts the invariant directly rather than through a real-time run,
/// because reproducing accumulation behaviorally needs seconds of wall clock
/// and a jitter assumption. Two properties, both durable:
///
/// - while the pacer keeps up, N accepted sends advance the gate by exactly
/// N spacings from the first anchor, whatever lateness each send is
/// observed with;
/// - a gate the pacer has fallen a full spacing behind re-anchors on the
/// accept time instead, so a stranded gate cannot release a burst of
/// catch-up sends.
#[test]
fn the_stagger_gate_advances_by_one_spacing_per_send_without_accumulating_lateness() {
// No socket is involved: the gate is driven directly and the task is never
// polled, so an unreachable address is all a valid target set needs here.
let (mut task, _) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![target("only", "127.0.0.1:9".parse().unwrap())],
)
.unwrap();
let spacing = Duration::from_millis(50);
let start = Instant::now();
let lateness = Duration::from_millis(3);
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
start,
);
assert_eq!(task.send_gate, Some(start + spacing), "first send anchors");
// Every later send is observed `lateness` after the gate that released it.
// The gate must still advance one spacing per send from `start`, not
// `spacing + lateness`.
for slot in 1..=8_u32 {
let released_at = start + spacing * slot;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
released_at + lateness,
);
assert_eq!(
task.send_gate,
Some(start + spacing * (slot + 1)),
"slot {slot} accumulated observed lateness into the gate"
);
}
// A full spacing behind is no longer the same cadence, so the gate
// re-anchors rather than staying stranded in the past.
let stalled_gate = task.send_gate.expect("the gate is set");
let resumed_at = stalled_gate + spacing;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
resumed_at,
);
assert_eq!(task.send_gate, Some(resumed_at + spacing));
}
#[test] #[test]
fn timeout_discovery_inspects_at_most_one_budget_of_targets() { fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
let target_count = TIMEOUT_WORK_BUDGET + 1; let target_count = TIMEOUT_WORK_BUDGET + 1;
+1 -2
View File
@@ -39,8 +39,7 @@ pub(crate) struct PendingProbe {
/// eligible only for upstream one-way delay after local plausibility /// eligible only for upstream one-way delay after local plausibility
/// validation against `tx_not_before_wall` (see `compute_one_way`'s /// validation against `tx_not_before_wall` (see `compute_one_way`'s
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for /// `preferred_send_wall`). `sent_at` remains the userspace fallback for
/// upstream one-way delay when this is absent, implausible, or kernel-ID /// upstream one-way delay when this is absent or implausible.
/// correlation was invalidated by a failed probe submission.
pub kernel_tx_timestamp: Option<SystemTime>, pub kernel_tx_timestamp: Option<SystemTime>,
} }
+8 -11
View File
@@ -12,17 +12,14 @@
//! After a successful Open, the client adapter best-effort upgrades the //! After a successful Open, the client adapter best-effort upgrades the
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`] //! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic //! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence //! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
//! after successful sends, so it is used as a best-effort correlation ID. //! submitted datagram is used directly as the probe's wire sequence number
//! A kernel ID can theoretically be consumed by a send that later fails, //! (see the client crate's `AGENTS.md` for the correlation invariant this
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards //! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
//! unmatched or implausible records; the userspace `sent_at` timestamp remains //! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
//! the fallback. After a probe send failure, correlation is disabled for that //! timestamps without ever waiting for one. The observed kernel TX wall time
//! session so later IDs cannot be misattributed. It performs a small bounded, //! is retained as metadata only; it is not consumed by any measurement in
//! nonblocking read of //! this change.
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
//! without ever waiting for one. The observed kernel TX wall time is retained
//! as metadata only; it is not consumed by any measurement in this change.
use std::{ use std::{
io::{self, IoSliceMut}, io::{self, IoSliceMut},
@@ -624,14 +624,22 @@ mod tests {
)); ));
} }
// NOTE: There is no live test for whether a failed nonblocking `sendmsg` // NOTE: a live test proving that a failed nonblocking `sendmsg` does not
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock` // consume an automatic OPT_ID is not included here. Forcing a reliable,
// on a connected loopback UDP socket (for example through socket-buffer // non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
// saturation) has no deterministic OS-level mechanism in this environment, // socket-buffer saturation) has no deterministic OS-level mechanism
// and the repository's testing policy forbids sleep/saturation-based // available in this environment, and the repository's testing policy
// flakiness. The correlation is intentionally best effort: a kernel ID can // explicitly forbids faking coverage with sleep/saturation-based
// theoretically be consumed by a send that later fails. The deterministic // flakiness. That safety is instead established from Linux kernel
// adapter test covers only the local half: an injected `WouldBlock` does // source: `_sock_tx_timestamp` (include/net/sock.h) and
// not advance the local wire-sequence counter. Any later unmatched kernel // `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
// timestamp is discarded and the userspace `sent_at` remains the fallback. // 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`.
} }
+5 -43
View File
@@ -60,7 +60,6 @@ struct ActiveSession {
pending: PendingMap, pending: PendingMap,
timed_out: TimedOutMap, timed_out: TimedOutMap,
completed: CompletedSet, completed: CompletedSet,
kernel_tx_correlation_valid: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -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 /// Record an observed Linux kernel TX timestamp for `wire_seq`, the
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the /// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
/// datagram was submitted. /// datagram was submitted.
/// ///
/// `wire_seq` normally matches that correlation ID after successful /// `wire_seq` doubles as that correlation ID: both counters start at
/// sends. This is best effort rather than an invariant: a kernel ID can /// zero for a session's first timestamped send and advance by exactly
/// theoretically be consumed by a send that later fails, leaving the /// one only on a confirmed successful submission, so they stay
/// counters desynchronized. An unmatched or implausible ID is discarded; /// identical for the life of one open session (see the client crate's
/// the probe's userspace `sent_at` remains the fallback. /// `AGENTS.md` for the full invariant).
/// ///
/// Updates a still-pending or already-timed-out probe in place. Never /// Updates a still-pending or already-timed-out probe in place. Never
/// resurrects a completed, evicted, or unknown probe, and never touches /// resurrects a completed, evicted, or unknown probe, and never touches
@@ -456,9 +443,6 @@ impl SessionMachine {
let MachineState::Open(session) = &mut self.state else { let MachineState::Open(session) = &mut self.state else {
return; return;
}; };
if !session.kernel_tx_correlation_valid {
return;
}
if let Some(probe) = session.pending.get_mut(wire_seq) { if let Some(probe) = session.pending.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp); probe.kernel_tx_timestamp.get_or_insert(timestamp);
return; return;
@@ -652,7 +636,6 @@ impl SessionMachine {
pending: PendingMap::new(self.config.max_pending_probes), pending: PendingMap::new(self.config.max_pending_probes),
timed_out: TimedOutMap::new(self.config.max_pending_probes), timed_out: TimedOutMap::new(self.config.max_pending_probes),
completed: CompletedSet::new(self.config.max_pending_probes), completed: CompletedSet::new(self.config.max_pending_probes),
kernel_tx_correlation_valid: true,
})); }));
let event = ClientEvent::SessionStarted { let event = ClientEvent::SessionStarted {
@@ -1248,7 +1231,6 @@ mod tests {
pending: PendingMap::new(max_pending_probes), pending: PendingMap::new(max_pending_probes),
timed_out: TimedOutMap::new(max_pending_probes), timed_out: TimedOutMap::new(max_pending_probes),
completed: CompletedSet::new(max_pending_probes), completed: CompletedSet::new(max_pending_probes),
kernel_tx_correlation_valid: true,
})); }));
machine machine
} }
@@ -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] #[test]
fn upstream_one_way_delay_falls_back_without_kernel_tx() { fn upstream_one_way_delay_falls_back_without_kernel_tx() {
let mono = Instant::now(); let mono = Instant::now();
@@ -3232,7 +3195,6 @@ mod tests {
pending: PendingMap::new(MAX_PENDING_PROBES), pending: PendingMap::new(MAX_PENDING_PROBES),
timed_out: TimedOutMap::new(MAX_PENDING_PROBES), timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
completed: CompletedSet::new(MAX_PENDING_PROBES), completed: CompletedSet::new(MAX_PENDING_PROBES),
kernel_tx_correlation_valid: true,
})); }));
(machine, params) (machine, params)
} }
-15
View File
@@ -12,14 +12,6 @@ use crate::{
socket_options::apply_ttl_to_socket, socket_options::apply_ttl_to_socket,
}; };
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
use crate::socket_options::apply_routing_options;
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> { pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
if timeouts.is_empty() { if timeouts.is_empty() {
return Err(ClientError::NoOpenTimeouts); return Err(ClientError::NoOpenTimeouts);
@@ -123,13 +115,6 @@ fn create_connected_udp_socket(
remote: SocketAddr, remote: SocketAddr,
) -> Result<UdpSocket, ClientError> { ) -> Result<UdpSocket, ClientError> {
let socket = create_prebind_udp_socket(config, remote)?; let socket = create_prebind_udp_socket(config, remote)?;
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
apply_routing_options(&socket, config, remote)?;
let bind_addr = config.bind_addr.unwrap_or_else(|| { let bind_addr = config.bind_addr.unwrap_or_else(|| {
if remote.is_ipv4() { if remote.is_ipv4() {
SocketAddr::from(([0, 0, 0, 0], 0)) SocketAddr::from(([0, 0, 0, 0], 0))
-53
View File
@@ -5,64 +5,11 @@ use std::{
use socket2::SockRef; use socket2::SockRef;
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
use socket2::Socket;
use crate::{ use crate::{
config::{MAX_DSCP_CODEPOINT, MAX_TTL}, config::{MAX_DSCP_CODEPOINT, MAX_TTL},
error::ClientError, error::ClientError,
}; };
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
pub(crate) fn apply_routing_options(
socket: &Socket,
config: &crate::SocketConfig,
remote: SocketAddr,
) -> Result<(), ClientError> {
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
{
if let Some(device) = &config.bind_to_device {
socket
.bind_device(Some(device.as_bytes()))
.map_err(|source| ClientError::SocketOption {
operation: "bind socket to device",
remote,
source,
})?;
}
if let Some(mark) = config.mark {
socket
.set_mark(mark)
.map_err(|source| ClientError::SocketOption {
operation: "set firewall mark",
remote,
source,
})?;
}
}
#[cfg(target_os = "freebsd")]
if let Some(fib) = config.fib {
socket
.set_fib(fib)
.map_err(|source| ClientError::SocketOption {
operation: "set FIB",
remote,
source,
})?;
}
Ok(())
}
/// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the /// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the
/// raw IP TOS / Traffic Class byte it occupies the upper six bits of. /// raw IP TOS / Traffic Class byte it occupies the upper six bits of.
/// ///
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.1" version = "0.5.0"
description = "IRTT protocol encoding and decoding primitives" description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
-44
View File
@@ -60,50 +60,6 @@ pub struct TimestampFields {
pub send_mono: Option<i64>, 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, &params, None).unwrap();
/// assert_eq!(decode_echo_reply(&packet, &params, None).unwrap(), reply);
/// ```
pub fn encode_echo_reply( pub fn encode_echo_reply(
reply: &EchoReply, reply: &EchoReply,
params: &Params, params: &Params,
-17
View File
@@ -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::InvalidHmacOffset`] when the field is truncated, and
/// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never /// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never
/// modified. /// 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: &params, 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<()> { pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
let envelope = envelope::decode_structural(packet)?; let envelope = envelope::decode_structural(packet)?;
if !envelope.hmac_present { if !envelope.hmac_present {
-14
View File
@@ -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 /// 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 /// resource bound, a maximum packet size — is deliberately not here. That is
/// server and runtime policy, and it belongs where the negotiation happens. /// 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> { pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params); let header_len = echo_header_len(hmac, params);
let requested = if params.length <= 0 { let requested = if params.length <= 0 {
-33
View File
@@ -42,39 +42,6 @@
//! which reply kind to expect, and the two need genuinely different semantic //! which reply kind to expect, and the two need genuinely different semantic
//! context. //! 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: &params, 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)] #![forbid(unsafe_code)]
pub mod echo; pub mod echo;
-40
View File
@@ -13,34 +13,6 @@ pub struct OpenReply {
pub params: Params, 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>> { pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
let params = reply.params.encode(); let params = reply.params.encode();
let mut out = envelope::begin_checked( 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) 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> { pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> {
let envelope = envelope::decode( let envelope = envelope::decode(
packet, packet,
-37
View File
@@ -16,29 +16,6 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open /// [`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 /// 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. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params { pub struct Params {
pub protocol_version: i64, pub protocol_version: i64,
@@ -111,20 +88,6 @@ impl Params {
/// Presence means the tag appeared at least once. Repeated known tags keep /// Presence means the tag appeared at least once. Repeated known tags keep
/// last-value-wins, and unknown tags remain ignored and untracked. /// 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 /// [`decode`]: Params::decode
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> { pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
let mut params = Self::default(); let mut params = Self::default();
-35
View File
@@ -88,26 +88,6 @@ pub enum DecodedRequestKind<'a> {
/// the negotiated length is positive and wider than `usize`. A *negative* /// the negotiated length is positive and wider than `usize`. A *negative*
/// negotiated length is not an error: see [`echo_packet_len`], which floors it /// negotiated length is not an error: see [`echo_packet_len`], which floors it
/// at the mandatory field block. /// 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: &params,
/// 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>> { pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
match request { match request {
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key), 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`], /// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for /// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
/// `FLAG_REPLY`. /// `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<'_>> { pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
let envelope = envelope::decode_structural(packet)?; let envelope = envelope::decode_structural(packet)?;
if has(envelope.flags, FLAG_REPLY) { if has(envelope.flags, FLAG_REPLY) {
-16
View File
@@ -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}; use crate::{ProtoError, Result};
pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) { pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) {
+7 -8
View File
@@ -500,14 +500,13 @@ session policy lives.
unmarked reply into a *dropped* one, because an unappliable marking stops the unmarked reply into a *dropped* one, because an unappliable marking stops the
send. Verify a platform before extending that list. send. Verify a platform before extending that list.
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped - A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers replies, and this is known. It is not fixed by choosing the option per reply,
matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT` for the macOS reason above, and it predates wildcard source selection. Leave it
listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that until it is worth its own slice, and do not "fix" it by weakening the rule that
pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not an unappliable marking stops the send. The crate forbids unsafe code;
weaken the rule that an unappliable marking stops the send. The crate forbids there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the
unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. target lists identical to the pinned `socket2` version's own gates — check
Keep the target lists identical to the pinned `socket2` version's own gates its source rather than copying another crate's older matrix.
— check its source rather than copying another crate's older matrix.
## Rate, lifetime and server-initiated close ## Rate, lifetime and server-initiated close
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-server" name = "irtt-server"
version = "0.5.3" version = "0.5.2"
description = "IRTT-compatible Rust server library" description = "IRTT-compatible Rust server library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
-22
View File
@@ -98,28 +98,6 @@ pub enum TimestampAllowance {
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and /// [`max_packet_length`](Self::max_packet_length) like every other reply, and
/// `none` is zero-filled here rather than left as residue. An operator /// `none` is zero-filled here rather than left as residue. An operator
/// restriction can be added if a real use case turns up. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig { pub struct ServerConfig {
hmac_key: Option<Vec<u8>>, hmac_key: Option<Vec<u8>>,
-26
View File
@@ -47,32 +47,6 @@ use crate::{
/// a transport abstraction, and there is no blocking or alternate-runtime /// a transport abstraction, and there is no blocking or alternate-runtime
/// counterpart. /// 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: &params, 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 /// # Scope
/// ///
/// The core implements open handling, session creation and negotiation, /// The core implements open handling, session creation and negotiation,
-22
View File
@@ -53,28 +53,6 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1);
/// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than /// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than
/// served by a listener whose replies a client on a second address would /// served by a listener whose replies a client on a second address would
/// silently discard. Explicit-address listeners are unaffected everywhere. /// 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)] #[derive(Debug)]
pub struct Server { pub struct Server {
socket: UdpSocket, socket: UdpSocket,
-31
View File
@@ -57,29 +57,6 @@ use crate::{Server, ServerConfig, ServerRuntimeError};
/// whole set: a service configured for IPv4 and IPv6 must not quietly continue /// 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 /// as IPv4 only. Every listener task is joined before `run` returns, so no task
/// outlives it. /// 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)] #[derive(Debug)]
pub struct ServerSet { pub struct ServerSet {
servers: Vec<Server>, servers: Vec<Server>,
@@ -114,14 +91,6 @@ impl ServerSet {
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener /// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
/// here, not an IPv6 one, matching how the runtime already reads it. /// here, not an IPv6 one, matching how the runtime already reads it.
/// ///
/// A lone explicit dual-stack `[::]:PORT` listener is not the recommended
/// configuration when reply DSCP/traffic-class marking for IPv4 peers
/// matters: Linux serves those peers as IPv4-mapped and cannot retain the
/// negotiated marking on their replies. Request the normal same-port
/// `[::]:PORT` and `0.0.0.0:PORT` pair instead. This method makes the IPv6
/// half IPv6-only for that pair only; a lone IPv6 listener retains its
/// normal socket behavior.
///
/// # Errors /// # Errors
/// ///
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set /// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
+1 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.1" version = "0.5.0"
description = "Statistics aggregation for irtt-client events" description = "Statistics aggregation for irtt-client events"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -12,7 +12,6 @@ keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"] categories = ["network-programming"]
[dependencies] [dependencies]
measurement-stats = "0.1.0"
irtt-client.workspace = true irtt-client.workspace = true
[[example]] [[example]]
-14
View File
@@ -10,11 +10,6 @@ This crate does not open sessions or drive sockets. Feed it the `ClientEvent`
stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed
session via `StatsCollector::process`, and read back a `Snapshot`. session via `StatsCollector::process`, and read back a `Snapshot`.
Generic timing accumulation and count-window storage come from
`measurement-stats`. `TimeStats` remains available from `irtt_stats` as a
re-export. Event interpretation, IPDV pairing, loss, and snapshot reconstruction
remain here.
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
scoped to a single target. A managed session covering multiple targets needs scoped to a single target. A managed session covering multiple targets needs
one collector per target — as the `irtt-rs` CLI does — since a target's one collector per target — as the `irtt-rs` CLI does — since a target's
@@ -31,15 +26,6 @@ bounded running statistics instead (no exact median) plus a bounded
unbounded sessions. Rolling-window snapshots are always running-only and unbounded sessions. Rolling-window snapshots are always running-only and
report no medians, whichever `StatsConfig` produced them. report no medians, whichever `StatsConfig` produced them.
An enabled time-based rolling window has no event-count cap, including in
continuous mode. It expires only on new events, anchored at their timestamps;
reading a snapshot does not advance time. Use count-based rolling storage for a
hard bound on retained events. The memory estimate excludes time-window storage.
Exact snapshots sort a temporary copy of each metric's retained samples, adding
O(n) scratch memory and O(n log n) computation for that metric. The retention
estimate is not peak snapshot or allocator accounting.
See `examples/` in the repository for a runnable comparison of both modes. See `examples/` in the repository for a runnable comparison of both modes.
## Documentation ## Documentation
+34 -40
View File
@@ -1,10 +1,9 @@
use std::time::Duration; use std::time::Duration;
use measurement_stats::TimeAccumulator;
use crate::ipdv::{IpdvSample, IpdvTracker}; use crate::ipdv::{IpdvSample, IpdvTracker};
use crate::loss::loss_stats; use crate::loss::loss_stats;
use crate::normalization::{ReplySample, StatsEvent}; use crate::normalization::{ReplySample, StatsEvent};
use crate::time_stats::TimeMetric;
use crate::{ use crate::{
EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, LateReplyMode, OneWayDelayStats, EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, LateReplyMode, OneWayDelayStats,
PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot, PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot,
@@ -16,17 +15,17 @@ pub(crate) const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
pub(crate) struct CoreStats { pub(crate) struct CoreStats {
events: EventCounts, events: EventCounts,
packets: PacketCounts, packets: PacketCounts,
send_call: TimeAccumulator, send_call: TimeMetric,
timer_error: TimeAccumulator, timer_error: TimeMetric,
rtt_primary: TimeAccumulator, rtt_primary: TimeMetric,
rtt_raw: TimeAccumulator, rtt_raw: TimeMetric,
rtt_adjusted: TimeAccumulator, rtt_adjusted: TimeMetric,
ipdv_round_trip: TimeAccumulator, ipdv_round_trip: TimeMetric,
ipdv_send: TimeAccumulator, ipdv_send: TimeMetric,
ipdv_receive: TimeAccumulator, ipdv_receive: TimeMetric,
send_delay: TimeAccumulator, send_delay: TimeMetric,
receive_delay: TimeAccumulator, receive_delay: TimeMetric,
server_processing: TimeAccumulator, server_processing: TimeMetric,
ipdv_tracker: IpdvTracker, ipdv_tracker: IpdvTracker,
late_replies: LateReplyMode, late_replies: LateReplyMode,
} }
@@ -43,25 +42,20 @@ impl CoreStats {
Some(CONTINUOUS_SEQUENCE_LIMIT) Some(CONTINUOUS_SEQUENCE_LIMIT)
}; };
let metric = || match sample_mode {
SampleMode::Exact => TimeAccumulator::exact_unbounded(),
SampleMode::RunningOnly => TimeAccumulator::running(),
};
Self { Self {
events: EventCounts::default(), events: EventCounts::default(),
packets: PacketCounts::default(), packets: PacketCounts::default(),
send_call: metric(), send_call: TimeMetric::new(retain_exact),
timer_error: metric(), timer_error: TimeMetric::new(retain_exact),
rtt_primary: metric(), rtt_primary: TimeMetric::new(retain_exact),
rtt_raw: metric(), rtt_raw: TimeMetric::new(retain_exact),
rtt_adjusted: metric(), rtt_adjusted: TimeMetric::new(retain_exact),
ipdv_round_trip: metric(), ipdv_round_trip: TimeMetric::new(retain_exact),
ipdv_send: metric(), ipdv_send: TimeMetric::new(retain_exact),
ipdv_receive: metric(), ipdv_receive: TimeMetric::new(retain_exact),
send_delay: metric(), send_delay: TimeMetric::new(retain_exact),
receive_delay: metric(), receive_delay: TimeMetric::new(retain_exact),
server_processing: metric(), server_processing: TimeMetric::new(retain_exact),
ipdv_tracker: IpdvTracker::new(sequence_limit), ipdv_tracker: IpdvTracker::new(sequence_limit),
late_replies, late_replies,
} }
@@ -233,8 +227,8 @@ impl CoreStats {
events: self.events, events: self.events,
packets, packets,
loss: loss_stats(packets), loss: loss_stats(packets),
send_call: self.send_call.snapshot(), send_call: self.send_call.stats(),
timer_error: self.timer_error.snapshot(), timer_error: self.timer_error.stats(),
rtt: self.rtt_stats(), rtt: self.rtt_stats(),
ipdv: self.ipdv_stats(), ipdv: self.ipdv_stats(),
one_way_delay: self.one_way_delay_stats(), one_way_delay: self.one_way_delay_stats(),
@@ -244,30 +238,30 @@ impl CoreStats {
fn rtt_stats(&self) -> RttStats { fn rtt_stats(&self) -> RttStats {
RttStats { RttStats {
primary: self.rtt_primary.snapshot(), primary: self.rtt_primary.stats(),
raw: self.rtt_raw.snapshot(), raw: self.rtt_raw.stats(),
adjusted: self.rtt_adjusted.snapshot(), adjusted: self.rtt_adjusted.stats(),
} }
} }
fn ipdv_stats(&self) -> IpdvStats { fn ipdv_stats(&self) -> IpdvStats {
IpdvStats { IpdvStats {
round_trip: self.ipdv_round_trip.snapshot(), round_trip: self.ipdv_round_trip.stats(),
send: self.ipdv_send.snapshot(), send: self.ipdv_send.stats(),
receive: self.ipdv_receive.snapshot(), receive: self.ipdv_receive.stats(),
} }
} }
fn one_way_delay_stats(&self) -> OneWayDelayStats { fn one_way_delay_stats(&self) -> OneWayDelayStats {
OneWayDelayStats { OneWayDelayStats {
send_delay: self.send_delay.snapshot(), send_delay: self.send_delay.stats(),
receive_delay: self.receive_delay.snapshot(), receive_delay: self.receive_delay.stats(),
} }
} }
fn server_processing_stats(&self) -> ServerProcessingStats { fn server_processing_stats(&self) -> ServerProcessingStats {
ServerProcessingStats { ServerProcessingStats {
processing: self.server_processing.snapshot(), processing: self.server_processing.stats(),
} }
} }
} }
+5 -257
View File
@@ -15,9 +15,8 @@
//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only //! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
//! running statistics — no exact median — and bounds its adjacent-sequence //! running statistics — no exact median — and bounds its adjacent-sequence
//! IPDV tracking, so memory stays bounded for a long-running or unbounded //! IPDV tracking, so memory stays bounded for a long-running or unbounded
//! session. An enabled time-based rolling window has no item cap and is not //! session. Rolling snapshots always use running statistics regardless of
//! hard-bounded; it expires only when new events arrive. Rolling snapshots //! this setting.
//! always use running statistics regardless of this setting.
//! //!
//! See `examples/` in the repository for a runnable comparison of both modes. //! See `examples/` in the repository for a runnable comparison of both modes.
@@ -35,13 +34,13 @@ mod loss;
mod normalization; mod normalization;
mod retention; mod retention;
mod rolling; mod rolling;
mod time_stats;
use core::CoreStats; use core::CoreStats;
pub use loss::LossStats; pub use loss::LossStats;
#[doc(inline)]
pub use measurement_stats::TimeStats;
use normalization::normalize_event; use normalization::normalize_event;
use rolling::RollingEvents; use rolling::RollingEvents;
pub use time_stats::TimeStats;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Configuration for statistics collection. /// Configuration for statistics collection.
@@ -55,10 +54,6 @@ pub struct StatsConfig {
/// and one unique reply event. /// and one unique reply event.
pub rolling_count: Option<usize>, pub rolling_count: Option<usize>,
/// Time span of recent normalized events retained for time-based rolling snapshots. /// Time span of recent normalized events retained for time-based rolling snapshots.
///
/// This window has no item cap, even in continuous mode. Its memory depends
/// on event volume inside the interval. Expiry occurs on insertion, anchored
/// at the latest event timestamp, not when a snapshot is read.
pub rolling_time: Option<Duration>, pub rolling_time: Option<Duration>,
/// Whether matched late replies contribute measurements. /// Whether matched late replies contribute measurements.
pub late_replies: LateReplyMode, pub late_replies: LateReplyMode,
@@ -71,44 +66,6 @@ impl StatsConfig {
/// them reports a median in the cumulative snapshot once it has samples, /// them reports a median in the cumulative snapshot once it has samples,
/// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent /// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
/// replies can still complete IPDV pairs. /// 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 { pub fn finite() -> Self {
Self { Self {
samples: SampleMode::Exact, samples: SampleMode::Exact,
@@ -122,46 +79,7 @@ impl StatsConfig {
/// ///
/// Continuous mode uses running statistics, retains no exact samples so no /// Continuous mode uses running statistics, retains no exact samples so no
/// timing metric reports a median, and bounds adjacent-sequence IPDV /// timing metric reports a median, and bounds adjacent-sequence IPDV
/// tracking for long-running sessions. An explicitly enabled `rolling_time` /// tracking for long-running sessions.
/// window is not hard-bounded; use `rolling_count` for bounded event storage.
///
/// # 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 { pub fn continuous() -> Self {
Self { Self {
samples: SampleMode::RunningOnly, samples: SampleMode::RunningOnly,
@@ -217,34 +135,6 @@ impl StatsConfig {
/// The result is deterministic and computed with saturating arithmetic, so /// The result is deterministic and computed with saturating arithmetic, so
/// an enormous `probe_count` saturates at [`u64::MAX`] rather than /// an enormous `probe_count` saturates at [`u64::MAX`] rather than
/// wrapping or panicking. /// 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 { pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
retention::estimated_retained_bytes(self, probe_count) retention::estimated_retained_bytes(self, probe_count)
} }
@@ -302,60 +192,6 @@ pub enum SampleMode {
/// ///
/// A collector maintains cumulative statistics and, when configured, rolling /// A collector maintains cumulative statistics and, when configured, rolling
/// windows. Rolling snapshots are recomputed from retained normalized events. /// 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 { pub struct StatsCollector {
cumulative: CoreStats, cumulative: CoreStats,
rolling: RollingEvents, rolling: RollingEvents,
@@ -400,53 +236,6 @@ impl StatsCollector {
/// running statistics only, whatever [`StatsConfig::samples`] is set to, /// running statistics only, whatever [`StatsConfig::samples`] is set to,
/// so its timing metrics report no median. A rolling window is a bounded /// so its timing metrics report no median. A rolling window is a bounded
/// recent view rather than the run's retained history. /// 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> { pub fn rolling_count(&self) -> Option<Snapshot> {
self.rolling.count_snapshot() self.rolling.count_snapshot()
} }
@@ -455,47 +244,6 @@ impl StatsCollector {
/// ///
/// Like [`StatsCollector::rolling_count`], this is recomputed with running /// Like [`StatsCollector::rolling_count`], this is recomputed with running
/// statistics only and reports no medians. /// 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> { pub fn rolling_time(&self) -> Option<Snapshot> {
self.rolling.time_snapshot() self.rolling.time_snapshot()
} }
+12 -12
View File
@@ -1,15 +1,14 @@
use std::{collections::VecDeque, time::Duration}; use std::{collections::VecDeque, time::Duration};
use measurement_stats::CountWindow;
use crate::{ use crate::{
core::CoreStats, normalization::StatsEvent, LateReplyMode, SampleMode, Snapshot, StatsConfig, core::CoreStats, normalization::StatsEvent, LateReplyMode, SampleMode, Snapshot, StatsConfig,
}; };
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub(crate) struct RollingEvents { pub(crate) struct RollingEvents {
count_limit: Option<usize>,
time_limit: Option<Duration>, time_limit: Option<Duration>,
count_events: Option<CountWindow<StatsEvent>>, count_events: Option<VecDeque<StatsEvent>>,
time_events: Option<VecDeque<StatsEvent>>, time_events: Option<VecDeque<StatsEvent>>,
late_replies: LateReplyMode, late_replies: LateReplyMode,
} }
@@ -17,16 +16,20 @@ pub(crate) struct RollingEvents {
impl RollingEvents { impl RollingEvents {
pub(crate) fn new(config: StatsConfig) -> Self { pub(crate) fn new(config: StatsConfig) -> Self {
Self { Self {
count_limit: config.rolling_count,
time_limit: config.rolling_time, time_limit: config.rolling_time,
count_events: config.rolling_count.map(CountWindow::new), count_events: config.rolling_count.map(|_| VecDeque::new()),
time_events: config.rolling_time.map(|_| VecDeque::new()), time_events: config.rolling_time.map(|_| VecDeque::new()),
late_replies: config.late_replies, late_replies: config.late_replies,
} }
} }
pub(crate) fn push(&mut self, event: StatsEvent) { pub(crate) fn push(&mut self, event: StatsEvent) {
if let Some(window) = self.count_events.as_mut() { if let (Some(limit), Some(window)) = (self.count_limit, self.count_events.as_mut()) {
window.push(event.clone()); window.push_back(event.clone());
while window.len() > limit {
window.pop_front();
}
} }
if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) { if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) {
@@ -43,22 +46,19 @@ impl RollingEvents {
pub(crate) fn count_snapshot(&self) -> Option<Snapshot> { pub(crate) fn count_snapshot(&self) -> Option<Snapshot> {
self.count_events self.count_events
.as_ref() .as_ref()
.map(|events| snapshot_window(events.iter(), self.late_replies)) .map(|events| snapshot_window(events, self.late_replies))
} }
pub(crate) fn time_snapshot(&self) -> Option<Snapshot> { pub(crate) fn time_snapshot(&self) -> Option<Snapshot> {
self.time_events self.time_events
.as_ref() .as_ref()
.map(|events| snapshot_window(events.iter(), self.late_replies)) .map(|events| snapshot_window(events, self.late_replies))
} }
} }
/// Recompute a window snapshot under the same normalized semantics as the /// Recompute a window snapshot under the same normalized semantics as the
/// cumulative collector, including its late-reply measurement policy. /// cumulative collector, including its late-reply measurement policy.
fn snapshot_window<'a>( fn snapshot_window(events: &VecDeque<StatsEvent>, late_replies: LateReplyMode) -> Snapshot {
events: impl Iterator<Item = &'a StatsEvent>,
late_replies: LateReplyMode,
) -> Snapshot {
let mut core = CoreStats::new(SampleMode::RunningOnly, late_replies); let mut core = CoreStats::new(SampleMode::RunningOnly, late_replies);
for event in events { for event in events {
core.apply(event.clone()); core.apply(event.clone());
+160
View File
@@ -0,0 +1,160 @@
#[derive(Debug, Clone, Copy, PartialEq)]
/// Summary statistics for timing values.
///
/// All fields use nanoseconds except `variance_ns2`, which is nanoseconds
/// squared. Values may represent signed timing quantities such as scheduling
/// error or one-way delay.
pub struct TimeStats {
/// Number of samples included in this summary.
pub count: u64,
/// Sum of all samples, in nanoseconds.
pub total_ns: i128,
/// Smallest sample, in nanoseconds.
pub min_ns: Option<i128>,
/// Largest sample, in nanoseconds.
pub max_ns: Option<i128>,
/// Arithmetic mean, in nanoseconds.
pub mean_ns: f64,
/// Median, in nanoseconds, when exact samples were retained.
///
/// This is `None` when the collector was not configured to retain exact
/// samples, and also when it was but this metric received none.
pub median_ns: Option<f64>,
/// Sample variance, in nanoseconds squared.
pub variance_ns2: f64,
}
impl TimeStats {
/// Returns the sample standard deviation, in nanoseconds.
pub fn stddev_ns(&self) -> f64 {
self.variance_ns2.sqrt()
}
/// Returns whether this summary contains no samples.
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TimeMetric {
running: RunningStats,
samples: Option<Vec<i128>>,
}
impl TimeMetric {
pub(crate) fn new(retain_samples: bool) -> Self {
Self {
running: RunningStats::default(),
samples: retain_samples.then(Vec::new),
}
}
pub(crate) fn push_ns(&mut self, value: i128) {
self.running.push(value);
if let Some(samples) = self.samples.as_mut() {
samples.push(value);
}
}
pub(crate) fn stats(&self) -> TimeStats {
self.running
.stats(self.samples.as_ref().and_then(|samples| median_ns(samples)))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
struct RunningStats {
count: u64,
total_ns: i128,
min_ns: Option<i128>,
max_ns: Option<i128>,
mean_ns: f64,
m2_ns2: f64,
}
impl RunningStats {
fn push(&mut self, value: i128) {
self.count += 1;
self.total_ns = self.total_ns.saturating_add(value);
self.min_ns = Some(self.min_ns.map_or(value, |min| min.min(value)));
self.max_ns = Some(self.max_ns.map_or(value, |max| max.max(value)));
let x = value as f64;
let delta = x - self.mean_ns;
self.mean_ns += delta / self.count as f64;
let delta2 = x - self.mean_ns;
self.m2_ns2 += delta * delta2;
}
fn stats(&self, median_ns: Option<f64>) -> TimeStats {
TimeStats {
count: self.count,
total_ns: self.total_ns,
min_ns: self.min_ns,
max_ns: self.max_ns,
mean_ns: if self.count == 0 { 0.0 } else { self.mean_ns },
median_ns,
variance_ns2: sample_variance(self.count, self.m2_ns2),
}
}
}
fn sample_variance(count: u64, m2: f64) -> f64 {
if count < 2 {
0.0
} else {
m2 / (count - 1) as f64
}
}
fn median_ns(samples: &[i128]) -> Option<f64> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let mid = sorted.len() / 2;
Some(if sorted.len() % 2 == 1 {
sorted[mid] as f64
} else {
(sorted[mid - 1] as f64 + sorted[mid] as f64) / 2.0
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn running_duration_stats_use_sample_variance() {
let mut metric = TimeMetric::new(false);
metric.push_ns(1);
metric.push_ns(2);
metric.push_ns(3);
let stats = metric.stats();
assert_eq!(stats.count, 3);
assert_eq!(stats.total_ns, 6);
assert_eq!(stats.min_ns, Some(1));
assert_eq!(stats.max_ns, Some(3));
assert_eq!(stats.mean_ns, 2.0);
assert_eq!(stats.variance_ns2, 1.0);
assert_eq!(stats.stddev_ns(), 1.0);
}
#[test]
fn exact_median_handles_odd_and_even_samples() {
assert_eq!(median_ns(&[3, 1, 2]), Some(2.0));
assert_eq!(median_ns(&[4, 1, 2, 3]), Some(2.5));
assert_eq!(median_ns(&[-5, 1, 3]), Some(1.0));
assert_eq!(median_ns(&[-5, 1, 3, 7]), Some(2.0));
}
#[test]
fn single_sample_stddev_is_zero() {
let mut metric = TimeMetric::new(false);
metric.push_ns(42);
let stats = metric.stats();
assert_eq!(stats.variance_ns2, 0.0);
assert_eq!(stats.stddev_ns(), 0.0);
}
}
+20
View File
@@ -13,6 +13,26 @@ use irtt_stats::{
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig, 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] #[test]
fn exact_mode_reports_a_timer_error_median() { fn exact_mode_reports_a_timer_error_median() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
+1 -1
View File
@@ -139,5 +139,5 @@ scenario contains the existing comparison harness output under
`netem-summary.json` files. Server stdout/stderr are captured at the run root as `netem-summary.json` files. Server stdout/stderr are captured at the run root as
`server.stdout` and `server.stderr`. `server.stdout` and `server.stderr`.
On macOS, `ip netns` and `tc netem` are unavailable; use the Gitea Actions On macOS, `ip netns` and `tc netem` are unavailable; use the GitHub Actions
workflow for netem coverage. workflow for netem coverage.
+7 -85
View File
@@ -6,7 +6,7 @@ irtt-client - IRTT-compatible stream client
## SYNOPSIS ## SYNOPSIS
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]... `irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]...
`irtt-client` `--list-columns` `irtt-client` `--list-columns`
@@ -18,39 +18,17 @@ eligible runs.
## TARGETS ## TARGETS
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix
`LABEL=` prefix assigns the logical target name used in output; without it, assigns the logical target name used in output; without it, the target
the target string itself is used as the label. Labeled and unlabeled targets string itself is used as the label. Labeled and unlabeled targets can be
can be freely mixed in one argument list. Prefer explicit labels for freely mixed in one argument list.
long-running dynamic target sets: the label is the stable target identity
across replacements.
```sh ```sh
irtt-client host-a:2112 irtt-client host-a:2112
irtt-client eu=host-a:2112 us=host-b:2112 irtt-client eu=host-a:2112 us=host-b:2112
irtt-client host-a:2112 eu=host-b:2112 irtt-client host-a:2112 eu=host-b:2112
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
``` ```
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting.
Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first
`=` is how an unlabeled target or a label can contain that character. An `=` in
an HMAC value, including Base64 padding, does not need escaping. Positional
target arguments may contain a raw comma; stdin target sets have the additional
`\,` escape described below.
Shells process unquoted backslashes and characters such as semicolons before
the client sees them. Single-quote a target when it contains parser escapes,
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
backslashes reach `irtt-client` unchanged.
Multiple targets are probed concurrently. By default, a multi-target session Multiple targets are probed concurrently. By default, a multi-target session
uses **staggered** pacing, spacing active targets across the probe interval; uses **staggered** pacing, spacing active targets across the probe interval;
pass `--pacing burst` to send one probe to every active target back-to-back pass `--pacing burst` to send one probe to every active target back-to-back
@@ -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 targets were supplied on the command line, not the order in which they
finish or their labels sort alphabetically. finish or their labels sort alphabetically.
## DYNAMIC TARGET SETS FROM STANDARD INPUT
`--targets-stdin` is available only with continuous mode (`--duration 0`).
Positional targets remain an initial desired set, and may be omitted to
start empty:
~~~sh
irtt-client --duration 0 --targets-stdin
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
~~~
Each non-empty stdin record declares the complete desired target set, rather
than a delta. A later record replaces an earlier set: targets absent from it
are retired, unchanged target configurations retain their generation, and a
changed address or HMAC setting creates a fresh generation. Under transient
live-generation backpressure, only the latest unapplied desired set is kept.
Commas frame targets in stdin records; escape a literal comma within one
target as `\,`. Target parsing inside each element is otherwise the same as
for positional arguments, including the other escapes above, `@hmac=KEY`, and
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
client is being controlled. For example, in one terminal:
~~~sh
mkfifo /tmp/irtt-targets
irtt-client --duration 0 --targets-stdin --hmac default-secret \
< /tmp/irtt-targets
~~~
Then open the FIFO once from another terminal and keep that descriptor open
while publishing complete desired sets:
~~~sh
exec 3> /tmp/irtt-targets
printf '%s\n' 'ams=ams.example:2112,sg=sg.example:2112@hmac=sg-secret' >&3
printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
# Publish more sets as needed, then send EOF and stop the client:
exec 3>&-
~~~
The exact record `[]` selects an empty desired set. Empty records are
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
or closure of the control plane and requests a graceful stop. A desired-state
revision that was read but has not yet been acknowledged as applied may be
discarded when EOF is observed; EOF does not wait for a capacity-rejected
revision to become applicable.
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
desired set to 128 targets and retains at most 256 live target generations
while replacements drain.
## FINITE VERSUS CONTINUOUS OPERATION ## FINITE VERSUS CONTINUOUS OPERATION
`--duration` controls the run length: `--duration` controls the run length:
@@ -118,9 +45,8 @@ while replacements drain.
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one). (see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
- `--duration 0` is **continuous**: the client runs until interrupted - `--duration 0` is **continuous**: the client runs until interrupted
(`Ctrl-C`). For table output, a final summary is printed only if the run (`Ctrl-C`). For table output, a final summary is printed only if the run
was interrupted. Stdin-controlled mode is the exception: EOF also requests was interrupted; an uninterrupted continuous run (one that exits for
a graceful stop and prints the final retained summary. An uninterrupted another reason) does not get one.
continuous run that exits for another reason does not get one.
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
distinction also determines what statistics can be computed and how much distinction also determines what statistics can be computed and how much
@@ -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 interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
mode it was in. mode it was in.
With `--targets-stdin`, peer closure is target-local: other desired targets
and the stdin controller continue running. Including that target in a later
complete set starts a fresh generation.
## EXIT STATUS ## EXIT STATUS
`0` on success (including a finite run's ordinary completion, and an `0` on success (including a finite run's ordinary completion, and an
+1 -1
View File
@@ -31,7 +31,7 @@ The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
what it is invoked or copied as — it does not consult argv0 dispatch logic. what it is invoked or copied as — it does not consult argv0 dispatch logic.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under `irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, or one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
`irtt-server` — it runs that applet directly, without needing a subcommand. `irtt-server` — it runs that applet directly, without needing a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even rejected as an unknown applet name before subcommand parsing is even
+1 -40
View File
@@ -60,17 +60,7 @@ served.
A port of `0` selects an unused port per listener, so two such binds get two A port of `0` selects an unused port per listener, so two such binds get two
different ports. different ports.
With no `--bind` at all, see [Description](#description) above for the With no `--bind` at all, see [Default bind](#default-bind) above.
default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
served through that socket are IPv4-mapped and their replies cannot retain the
negotiated marking. Configure the ordinary paired `[::]:PORT` and
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
same-port pair, so each family uses the correct socket option. This is a
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
IPv6 listener.
### `--hmac KEY` ### `--hmac KEY`
@@ -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: Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`. `ms`, `s`, or `m`.
## ENVIRONMENT
Every option above can also be supplied through an environment variable, for
deployments that configure by environment rather than by command line
(containers, orchestrators, unit files). A flag given on the command line
overrides the corresponding variable entirely; an unset variable leaves the
built-in default in place.
| Variable | Option |
| --- | --- |
| `IRTT_SERVER_BIND` | `--bind`, one address only |
| `IRTT_SERVER_HMAC` | `--hmac` |
| `IRTT_SERVER_MAX_SESSIONS` | `--max-sessions` |
| `IRTT_SERVER_MAX_PACKET_LENGTH` | `--max-packet-length` |
| `IRTT_SERVER_MIN_INTERVAL` | `--min-interval` |
| `IRTT_SERVER_BURST` | `--burst` |
| `IRTT_SERVER_IDLE_TIMEOUT` | `--idle-timeout` |
| `IRTT_SERVER_MAX_DURATION` | `--max-duration` |
| `IRTT_SERVER_TIMESTAMP_ALLOWANCE` | `--timestamp-allowance` |
| `IRTT_SERVER_NO_DSCP` | `--no-dscp` |
`IRTT_SERVER_BIND` takes exactly one address, the same as a single `--bind`;
there is no way to request several listeners from the environment. Serving
more than one address needs repeated `--bind` options.
`IRTT_SERVER_NO_DSCP` is the only boolean. `0`, `false`, `no`, `n`, `off` and
an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled.
## OUTPUT ## OUTPUT
Each bound endpoint is printed on startup, once every listener is up, which Each bound endpoint is printed on startup, once every listener is up, which
-8
View File
@@ -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 total memory is dominated by whichever of the two is larger for your target
count. count.
A **finite** `--duration` TUI run retains exact statistics samples exactly as
`irtt-client(1)` does in finite mode, so its retained state grows with the
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
prints **no** memory warning when that estimate gets large: it owns the
alternate screen, and a stderr warning there would be invisible or would
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
## EXIT BEHAVIOR ## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise, Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
+6 -133
View File
@@ -8,18 +8,6 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.1" version = "0.12.1"
@@ -31,9 +19,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.5" version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -47,12 +35,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
@@ -97,9 +79,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
@@ -144,24 +126,10 @@ dependencies = [
name = "irtt-proto-fuzz" name = "irtt-proto-fuzz"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"arbitrary",
"irtt-proto", "irtt-proto",
"irtt-server",
"libfuzzer-sys", "libfuzzer-sys",
] ]
[[package]]
name = "irtt-server"
version = "0.5.2"
dependencies = [
"getrandom",
"irtt-proto",
"nix",
"socket2",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.35" version = "0.1.35"
@@ -198,45 +166,6 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -267,16 +196,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -285,9 +204,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.5" version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -314,31 +233,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -350,24 +244,3 @@ name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+1 -12
View File
@@ -8,15 +8,11 @@ edition = "2021"
cargo-fuzz = true cargo-fuzz = true
[dependencies] [dependencies]
arbitrary = "1.4.2" libfuzzer-sys = "0.4"
libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto] [dependencies.irtt-proto]
path = "../crates/irtt-proto" path = "../crates/irtt-proto"
[dependencies.irtt-server]
path = "../crates/irtt-server"
# Use independent workspace for fuzzers # Use independent workspace for fuzzers
[workspace] [workspace]
members = ["."] members = ["."]
@@ -55,10 +51,3 @@ path = "fuzz_targets/decode_varint.rs"
test = false test = false
doc = false doc = false
bench = false bench = false
[[bin]]
name = "server_core_datagrams"
path = "fuzz_targets/server_core_datagrams.rs"
test = false
doc = false
bench = false
+6 -9
View File
@@ -1,7 +1,6 @@
# `irtt-rs` fuzzing # `irtt-proto` fuzzing
Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
`ServerCore::handle_datagram` API via
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer. [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
This project is intentionally isolated from the normal stable/MSRV This project is intentionally isolated from the normal stable/MSRV
@@ -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_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip | | `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` | | `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap |
The invariant every target asserts is: **arbitrary bytes must never panic**. The invariant every target asserts is: **arbitrary bytes must never panic**.
Returning `Err` is normal and expected; a successful decode is also normal. Returning `Err` is normal and expected; a successful decode is also normal.
Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
spends its time exploring protocol structure rather than allocating/copying libFuzzer spends its time exploring protocol structure rather than
megabytes. `server_core_datagrams` additionally caps its sequence length and allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
each individual datagram and configures the core to the same packet cap, so one under this bound.
libFuzzer input cannot become a tiny DoS benchmark.
## Running ## Running
-186
View File
@@ -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: &params,
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))
}