Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee b36842ee98 chore(release): publish docs for irtt-proto, irtt-server, irtt-stats
Rust CI / cargo fmt (push) Successful in 13s
Rust CI / cargo clippy (push) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 44s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m2s
Semver / cargo-semver-checks (push) Successful in 54s
Version the remaining public crates independently so their crate-specific READMEs, examples, and API doctests are represented in release history.
2026-09-25 00:32:11 +07:00
Nils Andreas Svee cf48988170 chore(release): irtt-client 0.5.2, irtt-rs 0.7.1
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo clippy (push) Successful in 29s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 43s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 7s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m8s
Semver / cargo-semver-checks (push) Successful in 58s
Publish irtt-server image / publish (push) Successful in 1m13s
Fix kernel transmit-timestamp correlation after failed probe sends and accept ordinary boolean values for the server DSCP environment option.
2026-09-25 00:29:52 +07:00
renovate Bot 41aee2a551 fix(deps): update rust crate clap to 4.6.7
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
2026-09-24 10:21:00 +00:00
renovate Bot 722539c8d7 chore(deps): update github actions to v2
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
2026-09-24 10:20:48 +00:00
renovate Bot 8369ec3502 chore(deps): update github actions
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
2026-09-24 10:19:59 +00:00
renovate Bot 1df69969db chore(deps): maintain cargo lockfile
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
2026-09-24 10:19:40 +00:00
Nils Andreas Svee 4908921fb8 Merge branch 'docs/doctests'
Rust CI / cargo fmt (push) Canceled after 0s
Rust CI / cargo clippy (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo doc (push) Canceled after 0s
Fuzz Smoke / cargo fuzz smoke (push) Canceled after 0s
Netem Interop / netem interop (push) Canceled after 0s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
2026-09-24 17:13:02 +07:00
Nils Andreas Svee 0d69255e9e docs(client): add executable API examples 2026-09-24 16:51:03 +07:00
Nils Andreas Svee d7d6e76682 docs(server): add executable API examples 2026-09-24 16:40:04 +07:00
Nils Andreas Svee 328ff17901 docs(irtt-proto): add runnable doctests for public API
Add executable doctests documenting the consumer-facing contract of
irtt-proto: the directional request encode/decode/verify split, Params
construction and round-trip, omitted-vs-explicit-zero presence, open and
echo reply round trips, packet length calculation, and HMAC verification.

No conventional tests are migrated: every existing irtt-proto test
exercises wire vectors, flag matrices, malformed input, layout rules,
HMAC error behavior, or encode/decode round trips, which the crate's
AGENTS.md treats as durable compatibility coverage rather than
documentation cleanup targets.
2026-09-24 16:29:40 +07:00
Nils Andreas Svee 19782dd3fd docs(irtt-stats): add runnable doctests for public API
Rust CI / cargo fmt (push) Successful in 22s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 42s
Rust CI / cargo clippy (push) Successful in 45s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m31s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo doc (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m44s
Supply chain / cargo-deny advisories (push) Successful in 10s
Netem Interop / netem interop (push) Successful in 1m13s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Add executable doctests that document the consumer-facing contract of
irtt-stats: finite vs continuous retention, the exact-median guarantee,
the retained-memory planning estimate, and count-/time-based rolling
snapshots. Migrate the send-call exact-median test into the
StatsConfig::finite() doc, which fully subsumes it.
2026-09-18 00:10:49 +07:00
Nils Andreas Svee b3241958d9 Merge branch 'test/server-core-fuzz'
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 39s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m21s
Rust CI / cargo doc (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m32s
Netem Interop / netem interop (push) Successful in 1m18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m49s
Supply chain / cargo-deny advisories (push) Successful in 10s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
2026-09-15 16:29:21 +07:00
Nils Andreas Svee 8644b70b89 test(server): vary fuzz HMAC keys 2026-09-15 14:24:51 +07:00
Nils Andreas Svee 196512068b Merge branch 'docs/audit-corrections'
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 43s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m29s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 38s
Rust CI / cargo doc (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m34s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m35s
Netem Interop / netem interop (push) Successful in 1m12s
Supply chain / actionlint (push) Successful in 4s
2026-09-15 13:20:25 +07:00
Nils Andreas Svee e861de5247 fix(client): invalidate TX correlation after send failures 2026-09-15 13:03:36 +07:00
Nils Andreas Svee d335ad78c9 test(server): exercise keyed stateful fuzz paths 2026-09-15 12:59:53 +07:00
Nils Andreas Svee f650b6220a Merge branch 'test/doctest-ci'
Rust CI / cargo fmt (push) Successful in 15s
Rust CI / cargo clippy (push) Successful in 37s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 40s
Rust CI / cargo doc (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m30s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Netem Interop / netem interop (push) Successful in 1m16s
Supply chain / actionlint (push) Successful in 3s
2026-09-15 12:51:01 +07:00
Nils Andreas Svee 865c166828 Merge branch 'refactor/remove-irttd-alias'
Rust CI / cargo fmt (push) Successful in 19s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Rust CI / cargo clippy (push) Successful in 42s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m28s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo doc (push) Successful in 21s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m31s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m31s
Netem Interop / netem interop (push) Successful in 1m14s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
2026-09-15 12:50:25 +07:00
Nils Andreas Svee bc2bf34ed4 test(server): fuzz ServerCore datagram handling 2026-09-15 12:39:59 +07:00
Nils Andreas Svee 4864357486 refactor(app): remove undocumented irttd applet alias 2026-09-15 12:32:34 +07:00
Nils Andreas Svee 188d6e5354 docs(client): clarify terminal open replies 2026-09-15 12:31:45 +07:00
Nils Andreas Svee 2882beb2c9 docs(client): clarify TX timestamp correlation 2026-09-15 12:31:26 +07:00
Nils Andreas Svee ed805e854f docs(server): document DSCP-safe listener pairing 2026-09-15 12:31:10 +07:00
Nils Andreas Svee f2e9c72b22 ci: run workspace doctests 2026-09-15 12:29:03 +07:00
Nils Andreas Svee 4563816873 docs(tui): advise on memory usage for finite-duration runs
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m27s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 42s
Rust CI / cargo doc (push) Successful in 25s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m29s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m29s
Supply chain / cargo-deny advisories (push) Successful in 8s
Netem Interop / netem interop (push) Successful in 1m22s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 4s
2026-09-14 15:33:57 +07:00
Nils Andreas Svee 70a2998f8c docs(server): update --bind default bind reference 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 4ddefb2101 docs(server): document all IRTT_SERVER_ environment variables 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 04291a0b7b feat(server): allow IRTT_SERVER_NO_DSCP to set --no-dscp flag 2026-09-14 15:33:57 +07:00
Nils Andreas Svee c426884fd1 fix(client): prevent stagger gate from accumulating wakeup latency 2026-09-14 15:33:56 +07:00
53 changed files with 1488 additions and 174 deletions

No files matched your search

+7 -4
View File
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: rustfmt
cache: false
@@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: clippy
cache: false
@@ -109,7 +109,7 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache: false
@@ -147,7 +147,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
@@ -158,3 +158,6 @@ jobs:
--all-features \
--no-deps \
--locked
- name: Run doctests
run: cargo test --workspace --doc --all-features --locked
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
# (native builds, no emulation), but buildx still needs a driver that
# can assemble a multi-platform manifest list.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -76,7 +76,7 @@ jobs:
# below), never a separate build, so the hash matches everywhere.
- name: Build and push to Gitea
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -1,4 +1,4 @@
name: Proto Fuzz Smoke
name: Fuzz Smoke
on:
push:
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false
- name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
@@ -71,7 +71,7 @@ jobs:
# regression; this is not meant to be exhaustive fuzzing.
run: |
set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
echo "::group::fuzz smoke: ${target}"
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
-max_total_time=10 \
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set.
- name: Run zizmor
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
persona: regular
inputs: |
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache-key: ${{ matrix.task }}-${{ matrix.target }}
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
@@ -40,7 +40,7 @@ jobs:
./
scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: clippy
cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
+1
View File
@@ -173,6 +173,7 @@ Use the narrowest useful checks first. For substantive workspace changes, final
cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
+30
View File
@@ -10,6 +10,12 @@ release. The format is loosely based on
## 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
#### Added
@@ -28,6 +34,12 @@ release. The format is loosely based on
## irtt-client
### 0.5.2
#### Fixed
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
### 0.5.1
#### Added
@@ -56,6 +68,12 @@ release. The format is loosely based on
## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2
#### Changed
@@ -95,6 +113,12 @@ release. The format is loosely based on
## 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
#### Fixed
@@ -108,6 +132,12 @@ release. The format is loosely based on
## irtt-rs
### 0.7.1
#### Fixed
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
### 0.7.0
#### Added
Generated
+45 -45
View File
@@ -213,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
@@ -223,9 +223,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
@@ -235,14 +235,14 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.4"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -403,7 +403,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -414,7 +414,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -710,12 +710,12 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
name = "irtt-client"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"irtt-proto",
"irtt-server",
@@ -738,7 +738,7 @@ dependencies = [
[[package]]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"hmac",
"md-5",
@@ -749,7 +749,7 @@ dependencies = [
[[package]]
name = "irtt-rs"
version = "0.7.0"
version = "0.7.1"
dependencies = [
"clap",
"crossterm",
@@ -764,7 +764,7 @@ dependencies = [
[[package]]
name = "irtt-server"
version = "0.5.2"
version = "0.5.3"
dependencies = [
"getrandom 0.4.3",
"irtt-proto",
@@ -776,7 +776,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"irtt-client",
"proptest",
@@ -805,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
@@ -887,9 +887,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.3"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
dependencies = [
"hashbrown 0.17.1",
]
@@ -943,9 +943,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"log",
@@ -1118,9 +1118,9 @@ dependencies = [
[[package]]
name = "pest"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
dependencies = [
"memchr",
"ucd-trie",
@@ -1128,9 +1128,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
dependencies = [
"pest",
"pest_generator",
@@ -1138,9 +1138,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
dependencies = [
"pest",
"pest_meta",
@@ -1151,9 +1151,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
dependencies = [
"pest",
]
@@ -1569,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1628,9 +1628,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socket2"
@@ -1705,9 +1705,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -1840,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1886,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1995,9 +1995,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
@@ -2008,9 +2008,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2018,22 +2018,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
+1
View File
@@ -171,6 +171,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
```sh
cargo fmt --all -- --check
cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-rs"
version = "0.7.0"
version = "0.7.1"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
@@ -47,7 +47,7 @@ server = ["dep:irtt-server", "dep:tokio"]
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
[dependencies]
clap = { version = "4.6.6", features = ["derive", "env"] }
clap = { version = "4.6.7", features = ["derive", "env"] }
crossterm = { version = "0.29.0", optional = true }
ctrlc = { version = "3.5.2", features = ["termination"] }
irtt-client = { workspace = true, optional = true }
+2 -1
View File
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
@@ -199,6 +199,7 @@ mod tests {
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irttd"), None);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
+50 -1
View File
@@ -106,10 +106,22 @@ pub struct ServerArgs {
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
//
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
// all. A flag's default parser accepts only the literals `true` and
// `false`, so the ordinary container/orchestrator spelling
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
// parsing and the server refused to start. Falsey semantics are clap's own
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
// `n`, `off` and empty are off, anything else is on, and an absent
// variable is off as before. The command-line flag is unaffected: it still
// takes no value and still means "on".
#[arg(
long,
env = "IRTT_SERVER_NO_DSCP",
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."
value_parser = clap::builder::FalseyValueParser::new(),
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
)]
pub no_dscp: bool,
}
@@ -548,6 +560,43 @@ mod tests {
);
}
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
/// boolean rather than a number, a duration or a string, and the
/// container/orchestrator deployments this whole environment surface
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
/// accepts only the literals `true`/`false` and turns everything else
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
/// server from starting at all rather than enabling the flag. Falsey
/// semantics are what keeps that from being a startup failure.
#[test]
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a truthy value must not fail argument parsing")
.server_config();
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
for value in ["0", "false", "no", "off", ""] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a falsey value must not fail argument parsing")
.server_config();
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
}
/// The command-line flag stays a flag: it takes no value, whatever the
/// environment parser accepts.
#[test]
fn the_no_dscp_flag_still_takes_no_command_line_value() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
}
#[test]
fn an_explicit_flag_overrides_the_environment() {
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
+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
failure is never a public error and is never logged from the library.
### Correlation invariant: kernel ID == wire sequence
### Best-effort kernel-ID correlation
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
sequence number — no separate correlation map. This is safe because:
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
correlation ID — no separate correlation map. On the normal successful-send
path, both counters start at zero after Open and advance together. This is
not a permanent identity: a kernel ID can theoretically be consumed while a
send is being prepared and then survive a subsequent send failure, leaving
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
assumption that failed or nonblocking sends cannot cause that desynchrony.
- **A failed/nonblocking send never consumes an ID.** Linux increments the
per-socket ID counter (`sk->sk_tskey`) speculatively while building a
datagram and rolls the increment back (`atomic_dec`) on every error path
out of `__ip_append_data`/`__ip6_append_data`
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
gap in, or otherwise advance, the ID the kernel will assign to the next
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
performs the same increment for the non-corked path.) Client-side, the
existing send transaction only calls `commit_probe_sent` — which is the
only thing that advances `next_wire_seq` — after a successful socket send;
a `WouldBlock` result never reaches it.
- **The two counters start together and advance together.** `wire_seq`
starts at 0 on every successful Open (`SessionMachine::commit_open` builds
a fresh `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1
only via `commit_probe_sent`, which only runs after a confirmed successful
send — the same condition under which the kernel's counter advances by 1.
TX timestamping is enabled only after that same successful Open and before
any probe is sent, so both counters' zero points line up exactly.
The effect is confined to the optional timing enhancement. A kernel timestamp
whose ID cannot be matched to a pending or retained timed-out probe is
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
userspace fallback. **After every failed probe submission, disable kernel-ID
correlation for the rest of that session.** A gap means an otherwise plausible
later ID can name the wrong probe, so subsequent error-queue timestamps must
be discarded rather than attached by ID. This is safe because:
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
successful Open (`SessionMachine::commit_open` builds a fresh
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
`commit_probe_sent`, after a confirmed successful socket send. TX
timestamping is enabled after that same successful Open and before any probe
is sent, so the normal zero points line up.
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-client"
version = "0.5.1"
version = "0.5.2"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
+33 -2
View File
@@ -242,6 +242,31 @@ impl AsyncClientTestHooks {
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
/// methods are polled by the caller and require a current Tokio runtime with I/O
/// and time enabled.
///
/// # Example
///
/// Use this low-level adapter when the application already owns Tokio and wants
/// to drive the session lifecycle itself. This example type-checks without
/// contacting a server.
///
/// ```no_run
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
/// let outcome = client.open().await?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe().await?;
/// println!("sent: {sent:?}");
/// let received = client.recv().await?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close().await?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct AsyncClient {
socket: tokio::net::UdpSocket,
@@ -608,8 +633,14 @@ impl AsyncClient {
// No sent_at measurement, no machine commit, no schedule
// commit: the transaction is fully discarded and retried
// with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
self.machine.invalidate_kernel_tx_correlation();
continue;
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(ClientError::Socket(error)));
}
};
#[cfg(not(test))]
+39 -6
View File
@@ -103,6 +103,31 @@ use crate::{
/// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use the unified managed API in
/// [`crate::managed`] when the `tokio` feature is enabled.
///
/// # Example
///
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
/// polling between sends; see the standalone example for a complete loop.
/// This example type-checks without contacting a server:
///
/// ```no_run
/// use irtt_client::{Client, ClientConfig};
///
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = Client::connect(ClientConfig::default())?;
/// let outcome = client.open()?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe()?;
/// println!("sent: {sent:?}");
/// let received = client.recv_once()?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close()?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct Client {
runtime: SessionMachine,
@@ -163,12 +188,13 @@ impl Client {
///
/// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated,
/// or unauthenticated datagrams are ignored until the current attempt's
/// absolute deadline, so one attempt may consume several datagrams without
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
/// unrelated datagrams are ignored until the current attempt's absolute
/// deadline, so one attempt may consume several datagrams without
/// retransmitting. Silence or ignored traffic eventually produces
/// [`ClientError::OpenTimeout`], while authenticated incompatibility remains
/// terminal.
/// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
/// incompatible Open reply from the connected peer is terminal; when HMAC
/// is configured, that recognition additionally requires authentication.
///
/// When a trusted reply allocates a token but later negotiation or socket
/// preparation fails, the client sends a best-effort cleanup close and
@@ -523,11 +549,18 @@ impl Client {
.unwrap_or_else(Instant::now);
#[cfg(test)]
if fail_send {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(io::Error::other(
"injected probe send failure",
)));
}
let bytes = socket.send(&prepared.bytes)?;
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
#[cfg(not(test))]
let send_finished_at = Instant::now();
#[cfg(test)]
+48
View File
@@ -46,6 +46,31 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams).
///
/// # Example
///
/// Configure a finite run with larger probes and accept documented server
/// restrictions. Use `duration: None` for continuous mode, or
/// [`RunMode::NoTest`] for negotiation without probes.
///
/// ```
/// use std::time::Duration;
///
/// use irtt_client::{ClientConfig, NegotiationPolicy};
///
/// let config = ClientConfig {
/// server_addr: "example.net:2112".into(),
/// duration: Some(Duration::from_secs(10)),
/// interval: Duration::from_millis(250),
/// length: 1200,
/// negotiation_policy: NegotiationPolicy::Loose,
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.duration, Some(Duration::from_secs(10)));
/// assert_eq!(config.interval, Duration::from_millis(250));
/// assert_eq!(config.length, 1200);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
/// Remote server name or address.
@@ -176,6 +201,29 @@ impl Default for ClientConfig {
/// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the
/// server.
///
/// # Example
///
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{ClientConfig, SocketConfig};
///
/// let config = ClientConfig {
/// socket_config: SocketConfig {
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
/// ttl: Some(32),
/// ipv4_only: true,
/// ..SocketConfig::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.socket_config.ttl, Some(32));
/// assert!(config.socket_config.ipv4_only);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
+13
View File
@@ -260,6 +260,19 @@ pub struct RttSample {
/// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`].
///
/// # Example
///
/// Signed timing values retain a negative result instead of clamping clock
/// skew or server processing adjustments to zero:
///
/// ```
/// use irtt_client::SignedDuration;
///
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
/// assert!(adjusted_rtt.is_negative());
/// assert_eq!(adjusted_rtt.as_micros(), -250);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration {
ns: i128,
+61 -1
View File
@@ -44,6 +44,42 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
/// Entry point for constructing a unified Tokio managed task.
///
/// # Example
///
/// Construct the task and its separate control handle, subscribe before
/// starting the task, then run it under the caller's Tokio runtime. A reachable
/// server is required at runtime, so this example only type-checks.
///
/// ```no_run
/// use irtt_client::{ClientConfig, ClientEvent};
/// use irtt_client::managed::{
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
/// };
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let config = ManagedClientConfig {
/// client: ClientConfig {
/// duration: Some(std::time::Duration::from_secs(10)),
/// ..ClientConfig::default()
/// },
/// ..ManagedClientConfig::default()
/// };
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
/// let (task, handle) = ManagedClient::task(config, targets)?;
/// let mut events = handle.subscribe()?;
/// let driver = tokio::spawn(task);
///
/// while let Ok(event) = events.recv().await {
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
/// println!("received a probe reply");
/// }
/// }
/// let outcome = driver.await?;
/// println!("run ended: {:?}", outcome.end_reason);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Default)]
pub struct ManagedClient;
@@ -1613,6 +1649,30 @@ impl ManagedClientTask {
self.send_gate = self.send_gate.filter(|gate| *gate > now);
}
/// The next stagger gate after a send was accepted at `accepted_at`.
///
/// The gate advances on its **own** cadence rather than being re-anchored
/// on every observed accept time. Anchoring on `accepted_at` folds each
/// send's wakeup latency into the next slot and then into every slot after
/// it, so the gate — and with it the whole group's send cadence — drifts
/// later without bound while the per-target schedules stay on their
/// absolute grid. The drift is not hypothetical: it shows up as a
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
/// interval, as probe slots the schedule then skips.
///
/// `accepted_at` is still the anchor in the two cases where continuing the
/// old cadence would be wrong: there is no previous gate, or the pacer has
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
/// stranded in the past from releasing a burst of catch-up sends once the
/// group starts moving again.
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
let base = match self.send_gate {
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
_ => accepted_at,
};
base.checked_add(spacing)
}
fn record_stagger_acceptance(
&mut self,
result: SendResult,
@@ -1626,7 +1686,7 @@ impl ManagedClientTask {
return;
};
self.last_stagger_send = Some(accepted_at);
self.send_gate = accepted_at.checked_add(spacing);
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
#[cfg(test)]
if let Some(observations) = &self.stagger_observations {
observations.lock().unwrap().push((_active, spacing));
+72
View File
@@ -1895,6 +1895,78 @@ fn stagger_gate_tracks_active_membership() {
second.finish();
}
/// The stagger gate must not fold each send's observed wakeup latency into
/// every later slot.
///
/// The gate is the binding constraint on when the *group* may send next (see
/// `next_deadline`), while each target's own schedule stays on an absolute
/// grid anchored at its open. Re-anchoring the gate on every observed accept
/// time therefore made the whole group's cadence drift later without bound:
/// `EchoSent::timer_error` grew monotonically, and once the drift exceeded one
/// interval `skip_missed_probe_slots_at` started dropping probe slots
/// outright. Measured before this was fixed, a single-target 20 s run at a
/// 50 ms interval sent 381 probes instead of 400.
///
/// This asserts the invariant directly rather than through a real-time run,
/// because reproducing accumulation behaviorally needs seconds of wall clock
/// and a jitter assumption. Two properties, both durable:
///
/// - while the pacer keeps up, N accepted sends advance the gate by exactly
/// N spacings from the first anchor, whatever lateness each send is
/// observed with;
/// - a gate the pacer has fallen a full spacing behind re-anchors on the
/// accept time instead, so a stranded gate cannot release a burst of
/// catch-up sends.
#[test]
fn the_stagger_gate_advances_by_one_spacing_per_send_without_accumulating_lateness() {
// No socket is involved: the gate is driven directly and the task is never
// polled, so an unreachable address is all a valid target set needs here.
let (mut task, _) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![target("only", "127.0.0.1:9".parse().unwrap())],
)
.unwrap();
let spacing = Duration::from_millis(50);
let start = Instant::now();
let lateness = Duration::from_millis(3);
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
start,
);
assert_eq!(task.send_gate, Some(start + spacing), "first send anchors");
// Every later send is observed `lateness` after the gate that released it.
// The gate must still advance one spacing per send from `start`, not
// `spacing + lateness`.
for slot in 1..=8_u32 {
let released_at = start + spacing * slot;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
released_at + lateness,
);
assert_eq!(
task.send_gate,
Some(start + spacing * (slot + 1)),
"slot {slot} accumulated observed lateness into the gate"
);
}
// A full spacing behind is no longer the same cadence, so the gate
// re-anchors rather than staying stranded in the past.
let stalled_gate = task.send_gate.expect("the gate is set");
let resumed_at = stalled_gate + spacing;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
resumed_at,
);
assert_eq!(task.send_gate, Some(resumed_at + spacing));
}
#[test]
fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
let target_count = TIMEOUT_WORK_BUDGET + 1;
+2 -1
View File
@@ -39,7 +39,8 @@ pub(crate) struct PendingProbe {
/// eligible only for upstream one-way delay after local plausibility
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
/// upstream one-way delay when this is absent or implausible.
/// upstream one-way delay when this is absent, implausible, or kernel-ID
/// correlation was invalidated by a failed probe submission.
pub kernel_tx_timestamp: Option<SystemTime>,
}
+11 -8
View File
@@ -12,14 +12,17 @@
//! After a successful Open, the client adapter best-effort upgrades the
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
//! submitted datagram is used directly as the probe's wire sequence number
//! (see the client crate's `AGENTS.md` for the correlation invariant this
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
//! timestamps without ever waiting for one. The observed kernel TX wall time
//! is retained as metadata only; it is not consumed by any measurement in
//! this change.
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
//! after successful sends, so it is used as a best-effort correlation ID.
//! A kernel ID can theoretically be consumed by a send that later fails,
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards
//! unmatched or implausible records; the userspace `sent_at` timestamp remains
//! the fallback. After a probe send failure, correlation is disabled for that
//! session so later IDs cannot be misattributed. It performs a small bounded,
//! nonblocking read of
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
//! without ever waiting for one. The observed kernel TX wall time is retained
//! as metadata only; it is not consumed by any measurement in this change.
use std::{
io::{self, IoSliceMut},
@@ -624,22 +624,14 @@ mod tests {
));
}
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not
// consume an automatic OPT_ID is not included here. Forcing a reliable,
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
// socket-buffer saturation) has no deterministic OS-level mechanism
// available in this environment, and the repository's testing policy
// explicitly forbids faking coverage with sleep/saturation-based
// flakiness. That safety is instead established from Linux kernel
// source: `_sock_tx_timestamp` (include/net/sock.h) and
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before
// a datagram is fully built and explicitly `atomic_dec` it back out on
// every error path (tracked via a local `hold_tskey` flag), so a failed
// send can never leave a gap in — or otherwise advance — the ID space
// the kernel hands out to successfully submitted datagrams. See the
// client crate's `AGENTS.md` for the full citation. The client-side
// half of this invariant (a `WouldBlock` `try_send` never advances the
// local wire-sequence counter used as the correlation ID) is covered by
// a deterministic test in the adapter module that injects `WouldBlock`.
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
// on a connected loopback UDP socket (for example through socket-buffer
// saturation) has no deterministic OS-level mechanism in this environment,
// and the repository's testing policy forbids sleep/saturation-based
// flakiness. The correlation is intentionally best effort: a kernel ID can
// theoretically be consumed by a send that later fails. The deterministic
// adapter test covers only the local half: an injected `WouldBlock` does
// not advance the local wire-sequence counter. Any later unmatched kernel
// timestamp is discarded and the userspace `sent_at` remains the fallback.
}
+43 -5
View File
@@ -60,6 +60,7 @@ struct ActiveSession {
pending: PendingMap,
timed_out: TimedOutMap,
completed: CompletedSet,
kernel_tx_correlation_valid: bool,
}
#[derive(Debug)]
@@ -423,15 +424,27 @@ impl SessionMachine {
}
}
/// Disable Linux kernel TX timestamp correlation after a probe send fails.
///
/// A failed submission can consume a kernel `SOF_TIMESTAMPING_OPT_ID`
/// without advancing the wire sequence. Existing associated timestamps
/// remain valid observations; later error-queue timestamps cannot be
/// associated safely and are ignored for the rest of this session.
pub(crate) fn invalidate_kernel_tx_correlation(&mut self) {
if let MachineState::Open(session) = &mut self.state {
session.kernel_tx_correlation_valid = false;
}
}
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
/// datagram was submitted.
///
/// `wire_seq` doubles as that correlation ID: both counters start at
/// zero for a session's first timestamped send and advance by exactly
/// one only on a confirmed successful submission, so they stay
/// identical for the life of one open session (see the client crate's
/// `AGENTS.md` for the full invariant).
/// `wire_seq` normally matches that correlation ID after successful
/// sends. This is best effort rather than an invariant: a kernel ID can
/// theoretically be consumed by a send that later fails, leaving the
/// counters desynchronized. An unmatched or implausible ID is discarded;
/// the probe's userspace `sent_at` remains the fallback.
///
/// Updates a still-pending or already-timed-out probe in place. Never
/// resurrects a completed, evicted, or unknown probe, and never touches
@@ -443,6 +456,9 @@ impl SessionMachine {
let MachineState::Open(session) = &mut self.state else {
return;
};
if !session.kernel_tx_correlation_valid {
return;
}
if let Some(probe) = session.pending.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp);
return;
@@ -636,6 +652,7 @@ impl SessionMachine {
pending: PendingMap::new(self.config.max_pending_probes),
timed_out: TimedOutMap::new(self.config.max_pending_probes),
completed: CompletedSet::new(self.config.max_pending_probes),
kernel_tx_correlation_valid: true,
}));
let event = ClientEvent::SessionStarted {
@@ -1231,6 +1248,7 @@ mod tests {
pending: PendingMap::new(max_pending_probes),
timed_out: TimedOutMap::new(max_pending_probes),
completed: CompletedSet::new(max_pending_probes),
kernel_tx_correlation_valid: true,
}));
machine
}
@@ -2873,6 +2891,25 @@ mod tests {
);
}
#[test]
fn invalidated_kernel_tx_correlation_uses_userspace_send_time() {
let mono = Instant::now();
let mut machine = upstream_probe(send_wall_ms(1_000), mono);
// A failed send can consume a kernel ID without advancing wire_seq.
// A later timestamp whose ID happens to match a pending wire sequence
// must not be associated after that gap.
machine.invalidate_kernel_tx_correlation();
machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005));
let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono);
assert_eq!(
reply_one_way(&events).unwrap().client_to_server,
Some(SignedDuration::from_nanos(20_000_000))
);
}
#[test]
fn upstream_one_way_delay_falls_back_without_kernel_tx() {
let mono = Instant::now();
@@ -3195,6 +3232,7 @@ mod tests {
pending: PendingMap::new(MAX_PENDING_PROBES),
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
completed: CompletedSet::new(MAX_PENDING_PROBES),
kernel_tx_correlation_valid: true,
}));
(machine, params)
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
+44
View File
@@ -60,6 +60,50 @@ pub struct TimestampFields {
pub send_mono: Option<i64>,
}
/// Encodes an ECHO reply.
///
/// `params` supplies the negotiated layout and length, exactly as on the
/// request side, and every optional field the layout requires must be present
/// in `reply`: a `Some` where the layout has no field, or a `None` where it has
/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC`
/// and signs the finished datagram.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when
/// the reply does not carry exactly the packet-type flags this encoder
/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`]
/// when `reply`'s optional fields do not match the negotiated layout, and
/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated
/// length.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats,
/// TimestampFields, FLAG_REPLY,
/// };
///
/// // `received_stats` negotiates a receive counter; four payload bytes.
/// let params = Params {
/// length: 24,
/// received_stats: ReceivedStats::Count,
/// ..Params::default()
/// };
/// let reply = EchoReply {
/// flags: FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// recv_count: Some(3),
/// recv_window: None,
/// timestamps: TimestampFields::default(),
/// payload: vec![1, 2, 3, 4],
/// };
///
/// let packet = encode_echo_reply(&reply, &params, None).unwrap();
/// assert_eq!(decode_echo_reply(&packet, &params, None).unwrap(), reply);
/// ```
pub fn encode_echo_reply(
reply: &EchoReply,
params: &Params,
+17
View File
@@ -68,6 +68,23 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()>
/// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and
/// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never
/// modified.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode};
///
/// // Synthetic example key — use a real shared secret in production.
/// let key = b"example key";
/// let params = Params::with_protocol_defaults();
/// let packet = encode_request(
/// RequestToEncode::Open { params: &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<()> {
let envelope = envelope::decode_structural(packet)?;
if !envelope.hmac_present {
+14
View File
@@ -135,6 +135,20 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize {
/// A ceiling on what a negotiated length may legitimately *be* — an MTU, a
/// resource bound, a maximum packet size — is deliberately not here. That is
/// server and runtime policy, and it belongs where the negotiation happens.
///
/// # Example
///
/// ```
/// use irtt_proto::{echo_packet_len, Params};
///
/// // The default layout's mandatory field block is 16 bytes (header, token,
/// // sequence), so a smaller requested length is floored up to it.
/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16);
/// assert_eq!(
/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(),
/// 40,
/// );
/// ```
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params);
let requested = if params.length <= 0 {
+33
View File
@@ -42,6 +42,39 @@
//! which reply kind to expect, and the two need genuinely different semantic
//! context.
//!
//! # Example
//!
//! The normal path is directional: a sender encodes with [`encode_request`], a
//! receiver classifies with [`decode_request`], and authentication is a
//! separate step once the applicable key is known.
//!
//! ```
//! use irtt_proto::{
//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params,
//! RequestToEncode,
//! };
//!
//! // Sender side: encode an authenticated open request.
//! let params = Params::with_protocol_defaults();
//! let key = b"example key";
//! let packet = encode_request(
//! RequestToEncode::Open { params: &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)]
pub mod echo;
+40
View File
@@ -13,6 +13,34 @@ pub struct OpenReply {
pub params: Params,
}
/// Encodes an open reply.
///
/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the
/// finished datagram, while `None` produces an unauthenticated reply and clears
/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply
/// also carries `FLAG_CLOSE` (a rejected open).
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or
/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without
/// `FLAG_CLOSE`.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY,
/// };
///
/// let reply = OpenReply {
/// flags: FLAG_OPEN | FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// params: Params::with_protocol_defaults(),
/// };
/// let packet = encode_open_reply(&reply, None).unwrap();
/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply);
/// ```
pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
let params = reply.params.encode();
let mut out = envelope::begin_checked(
@@ -30,6 +58,18 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<V
envelope::finish(out, hmac_key)
}
/// Decodes an open reply.
///
/// Requires `FLAG_OPEN` and `FLAG_REPLY`, and — when `hmac_key` is supplied —
/// verifies the authentication field. The decoded [`Params`] are validated:
/// malformed or incompatible parameter values are rejected here.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`], [`ProtoError::HmacPresenceMismatch`],
/// [`ProtoError::BadHmac`], [`ProtoError::PacketTooShort`],
/// [`ProtoError::ZeroToken`], or a parameter decode error. See
/// [`encode_open_reply`] for a round-trip example.
pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> {
let envelope = envelope::decode(
packet,
+37
View File
@@ -16,6 +16,29 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
/// request with an empty parameter payload means. It is not a set of sensible
/// client settings; a client builds its request from its own configuration.
///
/// # Example
///
/// Construct a `Params`, encode it, and decode it back:
///
/// ```
/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
///
/// let params = Params {
/// protocol_version: 1,
/// duration_ns: 3_000_000_000,
/// interval_ns: 1_000_000_000,
/// length: 1472,
/// received_stats: ReceivedStats::Both,
/// stamp_at: StampAt::Both,
/// clock: Clock::Both,
/// server_fill: Some(ServerFill { value: "rand".to_owned() }),
/// ..Params::default()
/// };
///
/// let encoded = params.encode();
/// assert_eq!(Params::decode(&encoded).unwrap(), params);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -88,6 +111,20 @@ impl Params {
/// Presence means the tag appeared at least once. Repeated known tags keep
/// last-value-wins, and unknown tags remain ignored and untracked.
///
/// # Example
///
/// ```
/// use irtt_proto::Params;
///
/// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval
/// // (tag 3) is absent, so both fields decode to zero but only one is
/// // reported present.
/// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap();
/// assert_eq!(decoded.params.duration_ns, 0);
/// assert!(decoded.presence.duration_ns);
/// assert!(!decoded.presence.interval_ns);
/// ```
///
/// [`decode`]: Params::decode
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
let mut params = Self::default();
+35
View File
@@ -88,6 +88,26 @@ pub enum DecodedRequestKind<'a> {
/// the negotiated length is positive and wider than `usize`. A *negative*
/// negotiated length is not an error: see [`echo_packet_len`], which floors it
/// at the mandatory field block.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, Params, RequestToEncode};
///
/// // An unauthenticated ECHO probe. `params` supplies the negotiated length,
/// // which reserves four payload bytes beyond the 16-byte echo header.
/// let params = Params { length: 20, ..Params::default() };
/// let packet = encode_request(
/// RequestToEncode::Echo {
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// params: &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>> {
match request {
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key),
@@ -138,6 +158,21 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) ->
/// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
/// `FLAG_REPLY`.
///
/// # Example
///
/// ```
/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode};
///
/// let packet = encode_request(
/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 },
/// None,
/// ).unwrap();
///
/// let request = decode_request(&packet).unwrap();
/// assert!(!request.hmac_present);
/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 });
/// ```
pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
let envelope = envelope::decode_structural(packet)?;
if has(envelope.flags, FLAG_REPLY) {
+16
View File
@@ -1,3 +1,19 @@
//! Variable-length integer encoding for IRTT wire values.
//!
//! Tags use an unsigned base-128 varint ([`encode_uvarint`]), and signed
//! integers (such as parameter values) use the same encoding after a zigzag
//! transform ([`encode_varint`]), which keeps small negative numbers compact.
//!
//! # Example
//!
//! ```
//! use irtt_proto::varint::{decode_varint, encode_varint};
//!
//! let mut out = Vec::new();
//! encode_varint(1_000_000_000, &mut out);
//! assert_eq!(decode_varint(&out).unwrap(), (1_000_000_000, out.len()));
//! ```
use crate::{ProtoError, Result};
pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) {
+8 -7
View File
@@ -500,13 +500,14 @@ session policy lives.
unmarked reply into a *dropped* one, because an unappliable marking stops the
send. Verify a platform before extending that list.
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
replies, and this is known. It is not fixed by choosing the option per reply,
for the macOS reason above, and it predates wildcard source selection. Leave it
until it is worth its own slice, and do not "fix" it by weakening the rule that
an unappliable marking stops the send. The crate forbids unsafe code;
there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the
target lists identical to the pinned `socket2` version's own gates — check
its source rather than copying another crate's older matrix.
replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers
matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT`
listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that
pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not
weaken the rule that an unappliable marking stops the send. The crate forbids
unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery.
Keep the target lists identical to the pinned `socket2` version's own gates
— check its source rather than copying another crate's older matrix.
## Rate, lifetime and server-initiated close
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-server"
version = "0.5.2"
version = "0.5.3"
description = "IRTT-compatible Rust server library"
edition.workspace = true
license.workspace = true
+22
View File
@@ -98,6 +98,28 @@ pub enum TimestampAllowance {
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and
/// `none` is zero-filled here rather than left as residue. An operator
/// restriction can be added if a real use case turns up.
///
/// # Example
///
/// Configure a modest per-listener session budget and restrict timestamp
/// placement while leaving DSCP marking disabled:
///
/// ```
/// use std::time::Duration;
///
/// use irtt_server::{ServerConfig, TimestampAllowance};
///
/// let config = ServerConfig::default()
/// .with_max_sessions(128)
/// .with_idle_timeout(Duration::from_secs(30))
/// .with_timestamp_allowance(TimestampAllowance::Single)
/// .with_dscp_allowed(false);
///
/// assert_eq!(config.max_sessions(), 128);
/// assert_eq!(config.idle_timeout(), Duration::from_secs(30));
/// assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
/// assert!(!config.dscp_allowed());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
hmac_key: Option<Vec<u8>>,
+26
View File
@@ -47,6 +47,32 @@ use crate::{
/// a transport abstraction, and there is no blocking or alternate-runtime
/// counterpart.
///
/// # Example
///
/// The core can be driven directly with a normally encoded open request. It
/// returns a reply to send and records the new session without performing I/O:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
/// use irtt_server::{ServerConfig, ServerCore};
///
/// let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 40_000));
/// let mut core = ServerCore::new(ServerConfig::default());
/// let params = Params::with_protocol_defaults();
/// let request = encode_request(
/// RequestToEncode::Open { params: &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
///
/// The core implements open handling, session creation and negotiation,
+22
View File
@@ -53,6 +53,28 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1);
/// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than
/// served by a listener whose replies a client on a second address would
/// silently discard. Explicit-address listeners are unaffected everywhere.
///
/// # Example
///
/// Bind one listener, inspect the resolved endpoint, and let the caller decide
/// when serving ends. This example type-checks without opening a socket:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{Server, ServerConfig};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let mut server = Server::bind(
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ServerConfig::default(),
/// )
/// .await?;
/// println!("listening on {}", server.local_addr()?);
/// server.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct Server {
socket: UdpSocket,
+31
View File
@@ -57,6 +57,29 @@ use crate::{Server, ServerConfig, ServerRuntimeError};
/// whole set: a service configured for IPv4 and IPv6 must not quietly continue
/// as IPv4 only. Every listener task is joined before `run` returns, so no task
/// outlives it.
///
/// # Example
///
/// Bind independent listeners, inspect their resolved addresses, then serve
/// them under one caller-owned shutdown future. This example type-checks
/// without binding sockets:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{ServerConfig, ServerSet};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let addrs = [
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ];
/// let set = ServerSet::bind(addrs, ServerConfig::default()).await?;
/// println!("listening on {:?}", set.local_addrs());
/// set.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct ServerSet {
servers: Vec<Server>,
@@ -91,6 +114,14 @@ impl ServerSet {
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
/// here, not an IPv6 one, matching how the runtime already reads it.
///
/// A lone explicit dual-stack `[::]:PORT` listener is not the recommended
/// configuration when reply DSCP/traffic-class marking for IPv4 peers
/// matters: Linux serves those peers as IPv4-mapped and cannot retain the
/// negotiated marking on their replies. Request the normal same-port
/// `[::]:PORT` and `0.0.0.0:PORT` pair instead. This method makes the IPv6
/// half IPv6-only for that pair only; a lone IPv6 listener retains its
/// normal socket behavior.
///
/// # Errors
///
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-stats"
version = "0.5.0"
version = "0.5.1"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
+246
View File
@@ -66,6 +66,44 @@ impl StatsConfig {
/// them reports a median in the cumulative snapshot once it has samples,
/// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
/// replies can still complete IPDV pairs.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us));
/// }
///
/// let snapshot = collector.snapshot();
/// // Finite mode retains exact samples, so the send-call metric reports an
/// // exact median once it has samples: the middle of the sorted
/// // 10, 20, 30 µs values, not their mean.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
/// ```
pub fn finite() -> Self {
Self {
samples: SampleMode::Exact,
@@ -80,6 +118,44 @@ impl StatsConfig {
/// Continuous mode uses running statistics, retains no exact samples so no
/// timing metric reports a median, and bounds adjacent-sequence IPDV
/// tracking for long-running sessions.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::continuous());
/// for seq in 0..3 {
/// collector.process(&sent(seq, u64::from(seq) * 10, 10));
/// }
///
/// let snapshot = collector.snapshot();
/// // Continuous mode still tracks running statistics — count, mean, min,
/// // and max — but retains no exact samples, so it reports no median.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert!(snapshot.send_call.mean_ns > 0.0);
/// assert_eq!(snapshot.send_call.median_ns, None);
/// ```
pub fn continuous() -> Self {
Self {
samples: SampleMode::RunningOnly,
@@ -135,6 +211,34 @@ impl StatsConfig {
/// The result is deterministic and computed with saturating arithmetic, so
/// an enormous `probe_count` saturates at [`u64::MAX`] rather than
/// wrapping or panicking.
///
/// # Example
///
/// ```
/// use irtt_stats::StatsConfig;
///
/// // Finite retention grows with the probe count...
/// let finite = StatsConfig::finite();
/// assert!(
/// finite.estimated_retained_bytes(1_000_000)
/// > finite.estimated_retained_bytes(1_000)
/// );
///
/// // ...while continuous retention is bounded, so a far longer run is no
/// // more expensive.
/// let continuous = StatsConfig::continuous();
/// assert_eq!(
/// continuous.estimated_retained_bytes(1_000_000),
/// continuous.estimated_retained_bytes(10_000_000),
/// );
/// assert!(
/// continuous.estimated_retained_bytes(1_000_000)
/// < finite.estimated_retained_bytes(1_000_000)
/// );
///
/// // Enormous probe counts saturate rather than wrapping or panicking.
/// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX);
/// ```
pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
retention::estimated_retained_bytes(self, probe_count)
}
@@ -192,6 +296,60 @@ pub enum SampleMode {
///
/// A collector maintains cumulative statistics and, when configured, rolling
/// windows. Rolling snapshots are recomputed from retained normalized events.
///
/// # Example
///
/// Feed a short stream of `irtt-client` events to a finite-mode collector and
/// read back a cumulative snapshot:
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration};
/// #
/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s.
/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent {
/// # let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10),
/// # };
/// # let raw = Duration::from_millis(rtt_ms);
/// # ClientEvent::EchoReply {
/// # seq,
/// # remote,
/// # sent_at,
/// # received_at: ClientTimestamp {
/// # mono: sent_at.mono + raw,
/// # wall: sent_at.wall + raw,
/// # },
/// # rtt: RttSample {
/// # raw,
/// # adjusted: None,
/// # effective: SignedDuration::from_duration(raw),
/// # },
/// # server_timing: None,
/// # one_way: None,
/// # received_stats: None,
/// # bytes: 64,
/// # packet_meta: irtt_client::PacketMeta::default(),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&reply(seq as u32, rtt_ms));
/// }
///
/// let snapshot = collector.snapshot();
/// assert_eq!(snapshot.events.echo_replies, 3);
/// assert_eq!(snapshot.rtt.primary.count, 3);
/// // Finite mode retains exact samples, so the round-trip metric reports an
/// // exact median: the middle of the sorted 10, 20, 30 ms samples.
/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0));
/// ```
pub struct StatsCollector {
cumulative: CoreStats,
rolling: RollingEvents,
@@ -236,6 +394,53 @@ impl StatsCollector {
/// running statistics only, whatever [`StatsConfig::samples`] is set to,
/// so its timing metrics report no median. A rolling window is a bounded
/// recent view rather than the run's retained history.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_count: Some(2),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 20, 10));
///
/// // The window keeps only the last two normalized events...
/// let rolling = collector.rolling_count().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 2);
/// // ...and is recomputed with running statistics, so it reports no median
/// // even though the collector retains exact samples for its cumulative
/// // snapshot.
/// assert_eq!(rolling.send_call.median_ns, None);
///
/// // The cumulative snapshot still sees every event.
/// let cumulative = collector.snapshot();
/// assert_eq!(cumulative.packets.packets_sent, 3);
/// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0));
/// ```
pub fn rolling_count(&self) -> Option<Snapshot> {
self.rolling.count_snapshot()
}
@@ -244,6 +449,47 @@ impl StatsCollector {
///
/// Like [`StatsCollector::rolling_count`], this is recomputed with running
/// statistics only and reports no medians.
///
/// # Example
///
/// ```
/// use std::time::Duration;
///
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::Instant;
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_time: Some(Duration::from_millis(15)),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 30, 10));
///
/// // A 15 ms window anchored at the latest event (30 ms) retains only the
/// // events from the last 15 ms — here just the final send.
/// let rolling = collector.rolling_time().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 1);
/// ```
pub fn rolling_time(&self) -> Option<Snapshot> {
self.rolling.time_snapshot()
}
-20
View File
@@ -13,26 +13,6 @@ use irtt_stats::{
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig,
};
#[test]
fn exact_mode_reports_a_send_call_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
collector.process(&sent_with_timings(
seq as u32,
ts(seq as u64 * 10),
send_call_us,
2,
));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.send_call.count, 3);
assert_eq!(snapshot.send_call.min_ns, Some(10_000));
assert_eq!(snapshot.send_call.max_ns, Some(30_000));
// Sorted 10, 20, 30 us: the median is the middle sample, not the mean.
assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
}
#[test]
fn exact_mode_reports_a_timer_error_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
+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.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or
one of the recognized applet names — `irtt-client`, `irtt-tui`, or
`irtt-server` — it runs that applet directly, without needing a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even
+40 -1
View File
@@ -60,7 +60,17 @@ served.
A port of `0` selects an unused port per listener, so two such binds get two
different ports.
With no `--bind` at all, see [Default bind](#default-bind) above.
With no `--bind` at all, see [Description](#description) above for the
default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
served through that socket are IPv4-mapped and their replies cannot retain the
negotiated marking. Configure the ordinary paired `[::]:PORT` and
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
same-port pair, so each family uses the correct socket option. This is a
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
IPv6 listener.
### `--hmac KEY`
@@ -136,6 +146,35 @@ and they are sent unmarked. The session is not refused.
Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`.
## ENVIRONMENT
Every option above can also be supplied through an environment variable, for
deployments that configure by environment rather than by command line
(containers, orchestrators, unit files). A flag given on the command line
overrides the corresponding variable entirely; an unset variable leaves the
built-in default in place.
| Variable | Option |
| --- | --- |
| `IRTT_SERVER_BIND` | `--bind`, one address only |
| `IRTT_SERVER_HMAC` | `--hmac` |
| `IRTT_SERVER_MAX_SESSIONS` | `--max-sessions` |
| `IRTT_SERVER_MAX_PACKET_LENGTH` | `--max-packet-length` |
| `IRTT_SERVER_MIN_INTERVAL` | `--min-interval` |
| `IRTT_SERVER_BURST` | `--burst` |
| `IRTT_SERVER_IDLE_TIMEOUT` | `--idle-timeout` |
| `IRTT_SERVER_MAX_DURATION` | `--max-duration` |
| `IRTT_SERVER_TIMESTAMP_ALLOWANCE` | `--timestamp-allowance` |
| `IRTT_SERVER_NO_DSCP` | `--no-dscp` |
`IRTT_SERVER_BIND` takes exactly one address, the same as a single `--bind`;
there is no way to request several listeners from the environment. Serving
more than one address needs repeated `--bind` options.
`IRTT_SERVER_NO_DSCP` is the only boolean. `0`, `false`, `no`, `n`, `off` and
an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled.
## OUTPUT
Each bound endpoint is printed on startup, once every listener is up, which
+8
View File
@@ -62,6 +62,14 @@ presentation bounds on top of the client's own statistics retention (see
total memory is dominated by whichever of the two is larger for your target
count.
A **finite** `--duration` TUI run retains exact statistics samples exactly as
`irtt-client(1)` does in finite mode, so its retained state grows with the
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
prints **no** memory warning when that estimate gets large: it owns the
alternate screen, and a stderr warning there would be invisible or would
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
+133 -6
View File
@@ -8,6 +8,18 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -19,9 +31,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.4"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -35,6 +47,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cmov"
version = "0.5.4"
@@ -79,9 +97,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "getrandom"
@@ -126,10 +144,24 @@ dependencies = [
name = "irtt-proto-fuzz"
version = "0.0.0"
dependencies = [
"arbitrary",
"irtt-proto",
"irtt-server",
"libfuzzer-sys",
]
[[package]]
name = "irtt-server"
version = "0.5.2"
dependencies = [
"getrandom",
"irtt-proto",
"nix",
"socket2",
"thiserror",
"tokio",
]
[[package]]
name = "jobserver"
version = "0.1.35"
@@ -166,6 +198,45 @@ dependencies = [
"digest",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -196,6 +267,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -204,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -233,6 +314,31 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -244,3 +350,24 @@ name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+11
View File
@@ -8,11 +8,15 @@ edition = "2021"
cargo-fuzz = true
[dependencies]
arbitrary = "1.4.2"
libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto]
path = "../crates/irtt-proto"
[dependencies.irtt-server]
path = "../crates/irtt-server"
# Use independent workspace for fuzzers
[workspace]
members = ["."]
@@ -51,3 +55,10 @@ path = "fuzz_targets/decode_varint.rs"
test = false
doc = false
bench = false
[[bin]]
name = "server_core_datagrams"
path = "fuzz_targets/server_core_datagrams.rs"
test = false
doc = false
bench = false
+9 -6
View File
@@ -1,6 +1,7 @@
# `irtt-proto` fuzzing
# `irtt-rs` fuzzing
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public
`ServerCore::handle_datagram` API via
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
This project is intentionally isolated from the normal stable/MSRV
@@ -22,14 +23,16 @@ of the root workspace, requires nightly Rust, and is not built by
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap |
The invariant every target asserts is: **arbitrary bytes must never panic**.
Returning `Err` is normal and expected; a successful decode is also normal.
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
libFuzzer spends its time exploring protocol structure rather than
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
under this bound.
Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer
spends its time exploring protocol structure rather than allocating/copying
megabytes. `server_core_datagrams` additionally caps its sequence length and
each individual datagram and configures the core to the same packet cap, so one
libFuzzer input cannot become a tiny DoS benchmark.
## Running
+186
View File
@@ -0,0 +1,186 @@
#![no_main]
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use arbitrary::Unstructured;
use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
use irtt_server::{ServerConfig, ServerCore};
use libfuzzer_sys::fuzz_target;
/// Bounds one libFuzzer input as well as the work it can ask the core to do.
/// These are deliberately much smaller than a production UDP payload: the
/// target is stateful parser/session exploration, not packet-size throughput.
const MAX_INPUT_LEN: usize = 32 * 1024;
const MAX_DATAGRAMS: usize = 16;
const MAX_DATAGRAM_LEN: usize = 2 * 1024;
const MAX_SESSIONS: u8 = 8;
const MAX_HMAC_KEY_LEN: usize = 128;
#[derive(Clone)]
struct FuzzSession {
token: u64,
peer: SocketAddr,
params: Params,
}
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
let mut input = Unstructured::new(data);
let Ok(mut config) = config(&mut input) else {
return;
};
let hmac_key = match input.arbitrary::<bool>() {
Ok(true) => {
let Ok(length) = input.int_in_range(1..=MAX_HMAC_KEY_LEN) else {
return;
};
input.bytes(length).ok()
}
Ok(false) => None,
Err(_) => return,
};
if let Some(key) = hmac_key {
config = config.with_hmac_key(key);
}
let mut core = ServerCore::new(config);
let mut sessions = Vec::new();
for _ in 0..MAX_DATAGRAMS {
let Ok(operation) = input.arbitrary::<u8>() else {
break;
};
match operation % 4 {
// Keep arbitrary-byte admission coverage alongside the stateful
// production-encoded requests below.
0 => raw_datagram(&mut core, &mut input),
1 => open_session(&mut core, &mut input, hmac_key, &mut sessions),
2 => echo_session(&mut core, &mut input, hmac_key, &sessions),
_ => close_session(&mut core, &mut input, hmac_key, &mut sessions),
}
// Arbitrary network input is expected to be rejected or to produce an
// internal error in exceptional host conditions. It must never panic,
// and it may never grow the bounded table past its configured cap.
assert!(core.session_count() <= core.config().max_sessions());
}
});
fn config(input: &mut Unstructured<'_>) -> arbitrary::Result<ServerConfig> {
let max_sessions = usize::from(input.int_in_range(0..=MAX_SESSIONS)?);
Ok(ServerConfig::default()
.with_max_sessions(max_sessions)
.with_max_packet_length(MAX_DATAGRAM_LEN)
.with_min_send_interval(Duration::ZERO))
}
fn raw_datagram(core: &mut ServerCore, input: &mut Unstructured<'_>) {
let (Ok(peer), Ok(length)) = (peer(input), input.int_in_range(0..=MAX_DATAGRAM_LEN)) else {
return;
};
let Ok(packet) = input.bytes(length) else {
return;
};
let _ = core.handle_datagram(peer, packet);
}
fn open_session(
core: &mut ServerCore,
input: &mut Unstructured<'_>,
hmac_key: Option<&[u8]>,
sessions: &mut Vec<FuzzSession>,
) {
let Ok(peer) = peer(input) else {
return;
};
let params = session_params();
let request = encode_request(
RequestToEncode::Open {
params: &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))
}